Skip to content

Swift 6: complete concurrency checking #825

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 32 commits into from
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ddf78cc
Use SWIFT_STRICT_CONCURRENCY complete with Swift 5
laevandus Mar 28, 2025
b73c9c5
Merge branch 'develop' into swift-6
laevandus May 2, 2025
2f64d75
Make view models MainActor and fix isolation for controller delegates…
laevandus May 2, 2025
87292cf
Set Swift version to 6
laevandus May 6, 2025
ab7356d
Constraint and UIDevice errors
laevandus May 6, 2025
391b156
Try MainActor Appearance and non-isolated injected values
laevandus May 6, 2025
26dd6aa
Upgrade Nuke to 12.8
laevandus May 6, 2025
ef3bbc0
Fixed all the errors
laevandus May 7, 2025
bb0ed90
Fix last warnings
laevandus May 7, 2025
87fcb5a
Fix Swift 6 warnings in tests
laevandus May 7, 2025
994196f
Disable building with Xcode 15
laevandus May 7, 2025
4c94da4
Fix tests
laevandus May 7, 2025
6b78288
Fix tests
laevandus May 8, 2025
8eae0dd
Merge branch 'develop' into swift-6
laevandus May 8, 2025
6e1e8c9
Fix build warnings in the demo app
laevandus May 8, 2025
f10725c
Fix avatar sizes with LazyImage
laevandus May 8, 2025
c0b9111
Fix UI test where LazyImage now is treated as images, not otherElements
laevandus May 9, 2025
79ef1c1
Tidy here and there
laevandus May 9, 2025
e28503d
Use Swift 6 in the demo app
laevandus May 9, 2025
f8d9504
Merge branch 'develop' into swift-6
laevandus May 9, 2025
fa2872a
Fix runtime asserts
laevandus May 9, 2025
4b305c5
Fix Gif support which LazyImage dropped
laevandus May 9, 2025
1ab09e2
Merge branch 'develop' into swift-6
laevandus May 22, 2025
1170a52
Do not change view factory method signatures (opt-out from checking)
laevandus May 22, 2025
7a0cfe3
Rename MainActor.ensureIsolated to StreamConcurrency.onMain
laevandus May 23, 2025
7d0237c
Fix giphy image lookup since it is image directly (changes in Nuke)
laevandus May 23, 2025
9769ff4
Remove assumeIsolated because Swift 6 compiler does not require it
laevandus May 23, 2025
365f9f6
Merge branch 'develop' into swift-6
laevandus May 29, 2025
9087e48
Remove @MainActor from message actions for avoiding breaking changes
laevandus May 30, 2025
55d666c
Merge branch 'develop' into swift-6
laevandus Jun 5, 2025
db9e461
Merge branch 'develop' into swift-6
laevandus Jun 25, 2025
ad036e8
Merge branch 'develop' into swift-6
laevandus Jul 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/smoke-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ jobs:
build-xcode15:
name: Build SDKs (Xcode 15)
runs-on: macos-15
if: ${{ github.event.inputs.record_snapshots != 'true' }}
#if: ${{ github.event.inputs.record_snapshots != 'true' }}
if: false
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Xcode 15 builds are going away, disabling it for now and a cleanup happens separately

env:
XCODE_VERSION: "15.4"
steps:
Expand Down
2 changes: 1 addition & 1 deletion .swiftformat
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Stream rules
--header "\nCopyright © {year} Stream.io Inc. All rights reserved.\n"
--swiftversion 5.9
--swiftversion 6.0

--ifdef no-indent
--disable redundantType
Expand Down
2 changes: 1 addition & 1 deletion DemoAppSwiftUI/AppleMessageComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ struct AppleMessageComposerView<Factory: ViewFactory>: View, KeyboardReadable {
}
)
.offset(y: -composerHeight)
.animation(nil) : nil,
.animation(.none, value: viewModel.showCommandsOverlay) : nil,
alignment: .bottom
)
.modifier(factory.makeComposerViewModifier())
Expand Down
4 changes: 3 additions & 1 deletion DemoAppSwiftUI/ChannelHeader/ChooseChannelQueryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import StreamChatSwiftUI
import SwiftUI

struct ChooseChannelQueryView: View {
static let queryIdentifiers = ChannelListQueryIdentifier.allCases.sorted(using: KeyPathComparator(\.title))
static var queryIdentifiers: [ChannelListQueryIdentifier] {
ChannelListQueryIdentifier.allCases.sorted(by: { $0.title < $1.title })
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It was giving Sendable related error which did not feel right. Just switched to another sorting method.


var body: some View {
ForEach(Self.queryIdentifiers) { queryIdentifier in
Expand Down
2 changes: 1 addition & 1 deletion DemoAppSwiftUI/CreateGroupView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ struct SearchBar: View {
}
.padding(.trailing, 10)
.transition(.move(edge: .trailing))
.animation(.default)
.animation(.default, value: isEditing)
}
}
}
Expand Down
1 change: 0 additions & 1 deletion DemoAppSwiftUI/LoginView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ struct LoginView: View {
DemoUserView(user: user)
}
.padding(.vertical, 4)
.animation(nil)
}
.listStyle(.plain)

Expand Down
4 changes: 2 additions & 2 deletions DemoAppSwiftUI/LoginViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class LoginViewModel: ObservableObject {
return
}

DispatchQueue.main.async { [weak self] in
Task { @MainActor [weak self] in
Copy link
Contributor Author

Choose a reason for hiding this comment

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

DispatchQueue gives Sendable error for self

Copy link
Contributor

Choose a reason for hiding this comment

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

yes, we should use Task going forward

withAnimation {
self?.loading = false
UnsecureRepository.shared.save(user: credentials)
Expand All @@ -64,7 +64,7 @@ class LoginViewModel: ObservableObject {
return
}

DispatchQueue.main.async { [weak self] in
Task { @MainActor [weak self] in
withAnimation {
self?.loading = false
AppState.shared.userState = .loggedIn
Expand Down
2 changes: 1 addition & 1 deletion DemoAppSwiftUI/NotificationsHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class NotificationsHandler: NSObject, ObservableObject, UNUserNotificationCenter
return
}

DispatchQueue.main.async {
Task { @MainActor in
AppState.shared.userState = .loggedIn
self?.notificationChannelId = cid.description
}
Expand Down
14 changes: 7 additions & 7 deletions DemoAppSwiftUI/ViewFactoryExamples.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ class DemoAppFactory: ViewFactory {

func supportedMoreChannelActions(
for channel: ChatChannel,
onDismiss: @escaping () -> Void,
onError: @escaping (Error) -> Void
onDismiss: @escaping @Sendable() -> Void,
onError: @escaping @Sendable(Error) -> Void
) -> [ChannelAction] {
var actions = ChannelAction.defaultActions(
for: channel,
Expand Down Expand Up @@ -259,8 +259,8 @@ class CustomFactory: ViewFactory {
// Example for an injected action. Uncomment to see it in action.
func supportedMoreChannelActions(
for channel: ChatChannel,
onDismiss: @escaping () -> Void,
onError: @escaping (Error) -> Void
onDismiss: @escaping @Sendable() -> Void,
onError: @escaping @Sendable(Error) -> Void
) -> [ChannelAction] {
var defaultActions = ChannelAction.defaultActions(
for: channel,
Expand All @@ -269,7 +269,7 @@ class CustomFactory: ViewFactory {
onError: onError
)

let freeze = {
let freeze: @MainActor @Sendable() -> Void = {
let controller = self.chatClient.channelController(for: channel.cid)
controller.freezeChannel { error in
if let error = error {
Expand Down Expand Up @@ -300,8 +300,8 @@ class CustomFactory: ViewFactory {

func makeMoreChannelActionsView(
for channel: ChatChannel,
onDismiss: @escaping () -> Void,
onError: @escaping (Error) -> Void
onDismiss: @escaping @MainActor() -> Void,
onError: @escaping @MainActor(Error) -> Void
) -> some View {
VStack {
Text("This is our custom view")
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.9
// swift-tools-version:6.0

import Foundation
import PackageDescription
Expand Down
5 changes: 0 additions & 5 deletions Scripts/removePublicDeclarations.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,6 @@ do
replaceDeclaration 'signpost(log' 'signpost(nukeLog' $f
replaceDeclaration ' Cache(' ' NukeCache(' $f
replaceDeclaration ' Cache<' ' NukeCache<' $f
replaceDeclaration ' Image?' ' NukeImage?' $f
replaceDeclaration ' Image(' ' NukeImage(' $f
replaceDeclaration 'struct Image:' 'struct NukeImage:' $f
replaceDeclaration 'extension Image {' 'extension NukeImage {' $f
replaceDeclaration 'Content == Image' 'Content == NukeImage' $f
Comment on lines -36 to -40
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nuke was upgraded to 12.8 (supports Swift 6), some of the types are not there anymore

replaceDeclaration ' VideoPlayerView' ' NukeVideoPlayerView' $f
replaceDeclaration 'typealias Color' 'typealias NukeColor' $f
replaceDeclaration 'extension Color' 'extension NukeColor' $f
Expand Down
7 changes: 4 additions & 3 deletions Sources/StreamChatSwiftUI/Appearance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,21 @@ public class Appearance {
}

/// Provider for custom localization which is dependent on App Bundle.
public static var localizationProvider: (_ key: String, _ table: String) -> String = { key, table in
nonisolated(unsafe)
public static var localizationProvider: @Sendable(_ key: String, _ table: String) -> String = { key, table in
Bundle.streamChatUI.localizedString(forKey: key, value: nil, table: table)
}
Comment on lines +24 to 27
Copy link
Contributor Author

@laevandus laevandus May 8, 2025

Choose a reason for hiding this comment

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

Alternative could be making it @MainActor instead of opting out with nonisolated. But then all out generated L10n structs would need to be MainActor as well which will cause some troubles, since main actor isolation is not available everywhere where L10n is used. Probably fine to keep it unsafe for now.

Copy link
Contributor

Choose a reason for hiding this comment

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

this is fine

}

// MARK: - Appearance + Default

public extension Appearance {
static var `default`: Appearance = .init()
nonisolated(unsafe) static var `default`: Appearance = .init()
Comment on lines -32 to +33
Copy link
Contributor Author

@laevandus laevandus May 7, 2025

Choose a reason for hiding this comment

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

Appearance is a class in SwiftUI and its properties are accessible through InjectedValues.
@injected makes it impossible top make the Appearance itself @MainActor because @Injected(\.images) private var images can't ensure main actor isolation when a type/view X is created.
The other option would be to use a lock within Appearance to make sure everything is concurrency safe, but that feels like too much because the type itself is called from main anyway (if not mistakenly calling from background threads).

Copy link
Contributor

Choose a reason for hiding this comment

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

also fine to keep it like this, it anyway contains only UI stuff - shouldn't be called from a bg thread

}

/// Provides the default value of the `Appearance` class.
public struct AppearanceKey: EnvironmentKey {
public static let defaultValue: Appearance = Appearance()
public static var defaultValue: Appearance { .init() }
}

extension EnvironmentValues {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import StreamChat
import SwiftUI

/// View model for the `AddUsersView`.
class AddUsersViewModel: ObservableObject {
@MainActor class AddUsersViewModel: ObservableObject {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since view model factory is MainActor and view models are used from view, then it made more than sense to make all the view models main actor.


@Injected(\.chatClient) private var chatClient

Expand Down Expand Up @@ -46,9 +46,11 @@ class AddUsersViewModel: ObservableObject {
if !loadingNextUsers {
loadingNextUsers = true
searchController.loadNextUsers { [weak self] _ in
guard let self = self else { return }
self.users = self.searchController.userArray
self.loadingNextUsers = false
MainActor.ensureIsolated { [weak self] in
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Controllers do not ensure on the compilation level which actor is used. We can use any queue for controller completion handlers although the default is main. Therefore, we need to make sure that main is used. Applies to all the completion handlers and controller delegates.

guard let self = self else { return }
self.users = self.searchController.userArray
self.loadingNextUsers = false
}
}
}
}
Expand All @@ -57,16 +59,20 @@ class AddUsersViewModel: ObservableObject {
let filter: Filter<UserListFilterScope> = .notIn(.id, values: loadedUserIds)
let query = UserListQuery(filter: filter)
searchController.search(query: query) { [weak self] error in
guard let self = self, error == nil else { return }
self.users = self.searchController.userArray
MainActor.ensureIsolated { [weak self] in
guard let self = self, error == nil else { return }
self.users = self.searchController.userArray
}
}
}

private func searchUsers(term: String) {
searchController.search(term: searchText) { [weak self] error in
guard let self = self, error == nil else { return }
self.users = self.searchController.userArray.filter { user in
!self.loadedUserIds.contains(user.id)
MainActor.ensureIsolated { [weak self] in
guard let self = self, error == nil else { return }
self.users = self.searchController.userArray.filter { user in
!self.loadedUserIds.contains(user.id)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import StreamChat
import SwiftUI

// View model for the `ChatChannelInfoView`.
public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDelegate {
@MainActor public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDelegate {

@Injected(\.chatClient) private var chatClient

Expand Down Expand Up @@ -172,7 +172,7 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
loadAdditionalUsers()
}

public func leaveConversationTapped(completion: @escaping () -> Void) {
public func leaveConversationTapped(completion: @escaping @MainActor() -> Void) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

For avoiding actor isolation in the view. Better force main on the view model level.

if !channel.isDirectMessageChannel {
removeUserFromConversation(completion: completion)
} else {
Expand All @@ -195,20 +195,22 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
)
}

public func channelController(
nonisolated public func channelController(
_ channelController: ChatChannelController,
didUpdateChannel channel: EntityChange<ChatChannel>
) {
if let channel = channelController.channel {
self.channel = channel
if self.channel.lastActiveMembers.count > participants.count {
participants = channel.lastActiveMembers.map { member in
ParticipantInfo(
chatUser: member,
displayName: member.name ?? member.id,
onlineInfoText: onlineInfo(for: member),
isDeactivated: member.isDeactivated
)
MainActor.ensureIsolated {
if let channel = channelController.channel {
self.channel = channel
if self.channel.lastActiveMembers.count > participants.count {
participants = channel.lastActiveMembers.map { member in
ParticipantInfo(
chatUser: member,
displayName: member.name ?? member.id,
onlineInfoText: onlineInfo(for: member),
isDeactivated: member.isDeactivated
)
}
}
}
}
Expand All @@ -221,23 +223,27 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe

// MARK: - private

private func removeUserFromConversation(completion: @escaping () -> Void) {
private func removeUserFromConversation(completion: @escaping @MainActor() -> Void) {
guard let userId = chatClient.currentUserId else { return }
channelController.removeMembers(userIds: [userId]) { [weak self] error in
if error != nil {
self?.errorShown = true
} else {
completion()
MainActor.ensureIsolated { [weak self] in
if error != nil {
self?.errorShown = true
} else {
completion()
}
}
}
}

private func deleteChannel(completion: @escaping () -> Void) {
private func deleteChannel(completion: @escaping @MainActor() -> Void) {
channelController.deleteChannel { [weak self] error in
if error != nil {
self?.errorShown = true
} else {
completion()
MainActor.ensureIsolated { [weak self] in
if error != nil {
self?.errorShown = true
} else {
completion()
}
}
}
}
Expand All @@ -249,19 +255,21 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe

loadingUsers = true
memberListController.loadNextMembers { [weak self] error in
guard let self = self else { return }
self.loadingUsers = false
if error == nil {
let newMembers = self.memberListController.members.map { member in
ParticipantInfo(
chatUser: member,
displayName: member.name ?? member.id,
onlineInfoText: self.onlineInfo(for: member),
isDeactivated: member.isDeactivated
)
}
if newMembers.count > self.participants.count {
self.participants = newMembers
MainActor.ensureIsolated { [weak self] in
Copy link
Contributor

Choose a reason for hiding this comment

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

this crashes if it's not on the main actor, right? Do we really need that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is a custom addition (maybe we should rename it to be more clear about it) which makes sure the closure runs on the main thread. Note that one can set any queue to be the one used for completion handers (default is main). This custom addition just checks if we are currently on the main, if yes, just call the closure, otherwise jump on the main thread (since UI updates and MainActor view models require main).

The difference between:
Task { @MainActor in and MainActor.ensureIsolated is that the latter is quicker.

DispatchQueue.global().async {
            print("Simulate API calls and DB updates")
            DispatchQueue.main.async {
                print(Thread.isMainThread)
                Task { @MainActor in
                    print("Task main actor")
                }
                MainActor.ensureIsolated {
                    print("Ensure isolated")
                }
            }
        }

If this snippet runs, ensure isolated is called first.

I did some micro benchmarking just for fun and the difference is insignificant: we are talking about 1-10 microseconds. Therefore, I guess it makes sense to simplify this and get rid of the custom ensureIsolated.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I renamed the custom addition to StreamConcurrency.onMain. I tried to get rid of it, but loads of unit-tests will break because using a Task introduces slight delay.

guard let self = self else { return }
self.loadingUsers = false
if error == nil {
let newMembers = self.memberListController.members.map { member in
ParticipantInfo(
chatUser: member,
displayName: member.name ?? member.id,
onlineInfoText: self.onlineInfo(for: member),
isDeactivated: member.isDeactivated
)
}
if newMembers.count > self.participants.count {
self.participants = newMembers
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import StreamChat
import SwiftUI

/// View model for the `FileAttachmentsView`.
class FileAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDelegate {
@MainActor class FileAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDelegate {

@Published var loading = false
@Published var attachmentsDataSource = [MonthlyFileAttachments]()
Expand Down Expand Up @@ -68,17 +68,21 @@ class FileAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDel
if !loadingNextMessages {
loadingNextMessages = true
messageSearchController.loadNextMessages { [weak self] _ in
guard let self = self else { return }
self.updateAttachments()
self.loadingNextMessages = false
MainActor.ensureIsolated { [weak self] in
guard let self = self else { return }
self.updateAttachments()
self.loadingNextMessages = false
}
}
}
}

// MARK: - ChatMessageSearchControllerDelegate

func controller(_ controller: ChatMessageSearchController, didChangeMessages changes: [ListChange<ChatMessage>]) {
updateAttachments()
nonisolated func controller(_ controller: ChatMessageSearchController, didChangeMessages changes: [ListChange<ChatMessage>]) {
MainActor.ensureIsolated {
updateAttachments()
}
}

private func loadMessages() {
Expand All @@ -89,9 +93,11 @@ class FileAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDel

loading = true
messageSearchController.search(query: query, completion: { [weak self] _ in
guard let self = self else { return }
self.updateAttachments()
self.loading = false
MainActor.ensureIsolated { [weak self] in
guard let self = self else { return }
self.updateAttachments()
self.loading = false
}
})
}

Expand Down
Loading
Loading