Hi, could someone explain to me how is @MainActor
annotation propagated through a type hierarchy? This is my setup
public protocol Scoped {
func setup()
}
public class BaseScoped: Scoped {
final public let tasks = Tasks()
public func setup() {
}
}
@MainActor
public protocol ViewModel: Scoped {
associatedtype S where S: Equatable
var state: AnyPublisher<S, Never> { get }
}
public class BaseViewModel<S: Equatable>: BaseScoped, ViewModel {
let _state: CurrentValueRelay<S>
public var state: AnyPublisher<S, Never> {
_state.eraseToAnyPublisher()
}
public init(initialState: S) {
_state = CurrentValueRelay(initialState)
}
public override func setup() {
super.setup()
}
}
final class FooViewModel: BaseViewModel<Int> {
override func setup() {
Task {
log("Thread \(Thread.current)") <-------------------------------
}
}
}
Basically I want setup
in the FooViewModel to be on main actor. But it is not.
Even if the annotation is added on the BaseViewModel
.
Is it because of the BaseScoped
superclass? Can I make it work?
Since contexts outside of the main actor can have a reference to a BaseScoped
(or an arbitrary Scoped
), and they can call setup()
on it, there isn't a way to guarantee that its implementations/overrides will only be called on the main actor.
So the solution is to not inherit from BaseScoped
and copy it's body into BaseViewModel
?
@John_McCall I'm sorry could you please get back to me? Because I see more odd behavior.
From what you said I gather that the annotation propagates on to the type's direct functions & its descendants, i.e. if I were to put @MainActor
on FooViewModel
, the only way it would work is if I extract the body of setup
to a separate function like so
@MainActor
final class FooViewModel: BaseViewModel<Int> {
override func setup() {
Task {
logThread()
}
}
private func logThread() {
log("Thread \(Thread.current)") <-------------------------------
}
}
This all makes sense to me.
However I found this hierarchy setup which produces the task being instantiated from main actor context from within setup
which is Scoped
function
public protocol Scoped {
func setup()
}
@MainActor
protocol ViewModel: Scoped {
associatedtype S where S: Equatable
var state: AnyPublisher<S, Never> { get }
}
public class BaseViewModel<S: Equatable>: ViewModel {
final public var cancellables = Set<AnyCancellable>()
final private let _state: CurrentValueRelay<S>
public var state: AnyPublisher<S, Never> {
_state.eraseToAnyPublisher()
}
public init(_ initialState: S) {
_state = CurrentValueRelay(initialState)
}
public func setup() {
}
}
final class FooViewModel: BaseViewModel<FooViewModel.State> {
private let userLogouter: UserLogouter
private let schedulerProvider: SchedulerProvider
init(initialState: FooViewModel.State, schedulerProvider: SchedulerProvider, userLogouter: UserLogouter) {
self.schedulerProvider = schedulerProvider
self.userLogouter = userLogouter
super.init(initialState)
}
override func setup() {
Task {
log("Thread \(Thread.current)") <-------------------------------
}
}
}
(what I did is removed the BaseScoped
from the hierarchy)
Doesn't that contradict what you said that the setup
is Scoped
function so its cannot be guaranteed? or is it a bug?
I don't understand what you're asking. Your new example doesn't do anything from setup()
which is required to run on the main actor.
It's just a minimal example, to show that the log prints main thread, and I dont understand why, (granted its probably because of the @MainActor on ViewModel
), since you said setup
is from Scoped
which is not @MainActor,
so what's the reason @MainActor annotation ViewModel
works to change what actor setup
is ran on?
I'd expect that to not be that case
If I remember correctly, the default Task
initializer (as opposed to Task.detached
) will inherit the main actor if it's called from the main actor even if the static environment isn't constrained to the main actor, because on Darwin we're just being super-cautious about moving things off the main actor.
So, should I rely on this? Seems rather indicental and probably undocumented
If you need something to run on a particular actor, being explicit can't hurt.
Okay so the TLDR; of this thread is that the Scoped
interface breaks what I want to do?
I was hoping to have Scoped
as a root component interface.
Then it would have 2 children - a non-UI component for background work, say syncing data, which should not be run on main actor;
and a UI-related one, a ViewModel where setup
would be constraint to main actor, like this
protocol Scoped {
func setup()
}
final class SomeDataSyncer : Scoped {
func setup() {
Task {
let data = try await api.fetchData()
db.saveData(data)
}
}
}
@MainActor
protocol ViewModel : Scoped {}
final class SomeViewModel : ViewModel {
func setup() {
Task {
let data = try await db.loadData()
renderData(data) <---- called on main thread implicitly
}
}
}

Btw by being explicit, you mean like this?
class BaseViewModel : Scoped {
final func setup() {
setupOnMainActor()
}
@MainActor
setupOnMainActor() {
...
}
}