Publishing changes from background threads is not allowed

I'm trying to use Combine with a view but am getting this error message at runtime:

Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

I tried adding ".receive(on: RunLoop.main)" before each ".eraseToAnyPublisher()", but that didn't resolve the issue.

Here is my model code:

import Foundation
import Combine
import Regex

let postcodeRegex = "[A-Z]{1,2}[0-9]{1,2}[A-Z]?\\s?[0-9][A-Z]{2}"
let emailRegex = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$"

struct EmailAvailResponse: Decodable {
    var available: Bool
}

@MainActor
class SignupModel: ObservableObject {
    
    // Published Properties
    
    @Published var firstName = ""
    
    @Published var lastName = ""
    
    @Published var email = ""
    
    @Published var country = "+1 (US)"
    
    @Published var phoneNumber = ""
    
    @Published var password = ""
    
    @Published var confirmPassword = ""
    
    // Validation Publishers
    
    lazy var firstNameValidation: ValidationPublisher = {
        $firstName.nonEmptyValidator("First name is required!")
    }()
        
    lazy var lastNameValidation: ValidationPublisher = {
        $lastName.nonEmptyValidator("Last name is required!")
    }()
    
    lazy var emailValidationRequired: ValidationPublisher = {
        $email.nonEmptyValidator("Email is required!")
    }()
    
    lazy var emailValidationMatches: ValidationPublisher = {
        $email.matcherValidator(emailRegex, "Email is not valid!")
    }()
    
    lazy var phoneNumberValidation: ValidationPublisher = {
        $phoneNumber.nonEmptyValidator("Phone is required!")
    }()
    
    lazy var passwordValidation: ValidationPublisher = {
        $password.nonEmptyValidator("Password is required!")
    }()
    
    lazy var confirmPasswordValidation: ValidationPublisher = {
        $confirmPassword.combineLatest($password) { confirmPassword, password in
            guard confirmPassword.count > 0 else {
                return .failure(message: "Confirm Password is required!")
            }
            guard confirmPassword.count > 6 else {
                return .failure(message: "Passwords must be > 6 chars!")
            }
            guard password == confirmPassword else {
                return .failure(message: "Passwords do not match!")
            }
            return .success
        }
        .dropFirst()
        .receive(on: RunLoop.main) // <<—— run on main thread
        .eraseToAnyPublisher()
    }()
    
    func emailAvailable(_ email: String, completion: @escaping (Bool) -> ()) -> () {
        if email != "" {
            print("SignupModel: emailAvailable(): Validating email '" + email + "'...")
            let pattern = try! Regex(pattern: emailRegex)
            if pattern.matches(email) {
                guard let url = URL(string: "https://api.myapi.network/users/email/availability?email=" + email.lowercased()) else { fatalError("emailAvailable(): Missing URL") }
                
                var request = URLRequest(url: url)
                    
                request.setValue(K.apiKey, forHTTPHeaderField: "x-api-key")
                
                let dataTask = URLSession.shared.dataTask(with: request) { (data, response, error) in
                    if let error = error {
                        print("SignupModel: emailAvailable(): Request error: ", error)
                        completion(false)
                    }
                    
                    guard let response = response as? HTTPURLResponse else { return }
                
                    if response.statusCode == 200 {
                        guard let data = data else {
                            print("SignupModel: emailAvailable(): Response contained no data!")
                            return
                        }
                    
                        DispatchQueue.main.async {
                            do {
                                let decodedResp = try JSONDecoder().decode(EmailAvailResponse.self, from: data)
                                if (decodedResp.available) {
                                    print("SignupModel: emailAvailable(): Email available, returning true!")
                                    completion(true)
                                } else {
                                    print("SignupModel: emailAvailable(): Email NOT available, returning false!")
                                    completion(false)
                                }
                            } catch let error {
                                print("SignupModel: emailAvailable(): Error decoding: ", error)
                                completion(false)
                            }
                        }
                    } else {
                        print("SignupModel: emailAvailable(): Response status = \(response.statusCode)")
                        completion(true)
                    }
                }
                dataTask.resume()
            } else {
                print("SignupModel: emailAvailable(): Exiting gracefully because email '" + email + "' is not a valid email!")
                completion(true)
            }
        } else {
            print("SignupModel: emailAvailable(): Exiting because email is nil!")
            completion(true)
        }
    }
    
    lazy var emailValidationAvailable: ValidationPublisher = {
        return $email
            .debounce(for: 0.5, scheduler: RunLoop.main)
            .removeDuplicates()
            .flatMap { email in
                return Future { promise in
                    self.emailAvailable(email) { available in
                    promise(.success(available ? .success : .failure(message: "Email is already registered!")))
                }
            }
        }
        .dropFirst()
        .receive(on: RunLoop.main) // <<—— run on main thread
        .eraseToAnyPublisher()
    }()
     
    // Combined Publishers
        
    // These are split up by section as CombineLatest only supports
    // a maximum of 4 input publishers maximum.
    
    lazy var emailValidation: ValidationPublisher = {
        Publishers.CombineLatest3(
            emailValidationRequired,
            emailValidationMatches,
            emailValidationAvailable
        ).map { v1, v2, v3 in
            print("SignupModel: emailValidation: emailValidationRequired: \(v1)")
            print("SignupModel: emailValidation: emailValidationMatches: \(v2)")
            print("SignupModel: emailValidation: emailValidationAvailable: \(v3)")
            return [v1, v2, v3].allSatisfy { $0.isSuccess } ? .success : (
                [v1, v2].allSatisfy { $0.isSuccess } ? v3 : (
                    v1.isSuccess ? v2 : v1
                )
            )
        }.receive(on: RunLoop.main) // <<—— run on main thread
        .eraseToAnyPublisher()
    }()
    
    lazy var allNamesValidation: ValidationPublisher = {
        Publishers.CombineLatest4(
            firstNameValidation,
            lastNameValidation,
            emailValidation,
            phoneNumberValidation
        ).map { v1, v2, v3, v4 in
            print("SignupModel: allNamesValidation: firstNameValidation: \(v1)")
            print("SignupModel: allNamesValidation: lastNamesValidation: \(v2)")
            print("SignupModel: allNamesValidation: emailValidation: \(v3)")
            print("SignupModel: allNamesValidation: phoneNumberValidation: \(v4)")
            return [v1, v2, v3, v4].allSatisfy { $0.isSuccess } ? .success : .failure(message: "")
        }.receive(on: RunLoop.main) // <<—— run on main thread
        .eraseToAnyPublisher()
    }()
    
    lazy var allPassword: ValidationPublisher = {
        Publishers.CombineLatest(
            passwordValidation,
            confirmPasswordValidation
        ).map { v1, v2 in
            print("SignupModel: allPassword: passwordValidation: \(v1)")
            print("SignupModel: allPassword: confirmPasswordValidation: \(v2)")
            return [v1, v2].allSatisfy { $0.isSuccess } ? .success : .failure(message: "")
        }.receive(on: RunLoop.main) // <<—— run on main thread
        .eraseToAnyPublisher()
    }()
    
    lazy var allValidationRules: ValidationPublisher = {
        Publishers.CombineLatest(
            allNamesValidation,
            allPassword
        ).map { v1, v2 in
             print("SignupModel: allValidation: allNamesValidation: \(v1)")
             print("SignupModel: allValidation: allPassword: \(v2)")
             return [v1, v2].allSatisfy { $0.isSuccess } ? .success : .failure(message: "")
         }.receive(on: RunLoop.main) // <<—— run on main thread
         .eraseToAnyPublisher()
    }()
    
}

And the corresponding View code:

import SwiftUI
import Combine

struct LabelStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .font(.body)
            .foregroundColor(Color.black)
        
    }
}

extension Text {
    func textStyle<Style: ViewModifier>(_ style: Style) -> some View {
        ModifiedContent(content: self, modifier: style)
    }
}

extension Text {
    func label() -> Text {
        self
            .foregroundColor(Color.black)
            .fontWeight(.bold)
    }
}

struct CustomTextFieldStyle: TextFieldStyle {
    func _body(configuration: TextField<_Label>) -> some View {
        configuration
            .padding(10)
            .background(Color(red: 239.0/255.0, green: 243.0/255.0, blue: 244.0/255.0, opacity: 1.0))
            .cornerRadius(5.0)
    }
}

struct ActionButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundColor(.white)
            .font(Font.body.bold())
            .padding(10)
            .padding(.horizontal, 20)
            .background(Color.blue)
            .cornerRadius(10)
    }
}

struct SignupView: View {
    
    let countryList = ["+1 (US)", "+1 (CA)"]
    
    @ObservedObject var model = SignupModel()
    
    @State var isSaveDisabled = true
    @State var cardShown = false
    @State var cardDismissal = false
    
    var body: some View {
        ZStack {
            ScrollView {
                VStack {
                    HStack {
                        CustomTextField(label: "First Name", validation: model.firstNameValidation, textValue: $model.firstName)
                        CustomTextField(label: "Last Name", validation: model.lastNameValidation, textValue: $model.lastName)
                    }
                    
                    CustomTextField(label: "Email", validation: model.emailValidation, forceLowerCase: true, textValue: $model.email)
                    
                    CustomSecureField(label: "Password", validation: model.passwordValidation, textValue: $model.password)
                    CustomSecureField(label: "Confirm Password", validation: model.confirmPasswordValidation, textValue: $model.confirmPassword)
                    
                    Button(action: {
                        if !self.isSaveDisabled {
                            print("SignupView: Do Rest Call Here Dude")
                        }
                    }) {
                        Text("Sign Up").frame(width: UIScreen.main.bounds.width - 30, alignment: .center)
                    }.buttonStyle(CustomButtonStyle(backgroundColor: Color("Color-Accent"), foregroundColor: Color.white, isDisabled: self.isSaveDisabled))
                    .onReceive(model.allValidationRules) { validation in
                        print("SignupView:  Button onReceiving...")
                        self.isSaveDisabled = !validation.isSuccess
                    }
                    DisclaimerView()
                }
            }.padding()
            .onReceive(model.allValidationRules) { validation in
                print("SignupView:  Form onReceiving...")
                self.isSaveDisabled = !validation.isSuccess
            }
        }
    }
}

struct SignupView_Previews: PreviewProvider {
    static var previews: some View {
        SignupView()
    }
}

Anyone know what I'm missing? Thanks in advance!

Aaron

Where are you getting the error message?