Is this a bug in @Published?

Following was tested on :
Xcode Version: 11.3 beta (11C24b)
macOS version: 10.15.1 (19B88)
Platform: iOS Playground

Case 1 - not on main thread:

Code:

let s = t.valuePublisher
    .sink { value in
    print("closure value: \(value)")
    print("object value: \(t.value)")
}

t.value = 42
t.value = 14

Output:

closure value: 0
object value: 0
closure value: 42
object value: 0
closure value: 14
object value: 42

Case 2 - RunLoop.main:

Code:

let s = t.valuePublisher
    .receive(on: RunLoop.main)
    .sink { value in
    print("closure value: \(value)")
    print("object value: \(t.value)")
}

t.value = 42
t.value = 14

Output:

closure value: 0
object value: 14
closure value: 42
object value: 14
closure value: 14
object value: 14

Which OS do you target in Playground? If it‘s macOS then you also need latest Catalina beta as the framework is part of the OS. On the other hand if it‘s iOS then it should use the framework version from the sim that would have the new behavior.

The last output is completely unexpected. :exploding_head:
Can you also try DispatchQueue.main as the scheduler?

I am a newbie ... I am not sure if I am messing something up.

macOS version: 10.15.1 (19B88)
Platform: iOS Playground

Case 3: DispatchQueue.main

Code:

let s = t.valuePublisher
    .receive(on: DispatchQueue.main)
    .sink { value in
    print("closure value: \(value)")
    print("object value: \(t.value)")
}

t.value = 42
t.value = 14

Output:

closure value: 0
object value: 14
closure value: 42
object value: 14
closure value: 14
object value: 14
1 Like

I'd say it's quit expected. The events are rescheduled to the future (next runloop), after the last line execute.

It will be easier to understand:

let s = t.valuePublisher
    .receive(on: RunLoop.main)
    .sink { value in
    print("closure value: \(value)")
    print("object value: \(t.value)")
}

t.value = 42
print("set value to 42")
t.value = 14
print("set value to 14")

Output:

set value to 42
set value to 14
// next runloop, perform queued tasks one by one
closure value: 0
object value: 14
closure value: 42
object value: 14
closure value: 14
object value: 14

But isn't the point of sync that it happens right away in a blocking manner? Why would we need to wait until the next runloop cycle? (I must admit that I'm not an expert at that area.)

This is how RunLoop works as a scheduler. It can be easily tested:

ImmediateScheduler.shared.schedule { print("ImmediateScheduler") }
RunLoop.main.schedule { print("RunLoop") }
DispatchQueue.main.schedule { print("DispatchQueue") }
OperationQueue.main.schedule { print("OperationQueue") }
print("End of current runloop cycle")

output:

ImmediateScheduler
End of current runloop cycle
RunLoop
DispatchQueue
OperationQueue
2 Likes

Thanks for the explanation — it makes sense to me now why the output is what it is.

My original question remains, though: Should it be considered a bug that the published value and the actual value are different?

Unfortunately, to get the expected behavior, I can't rely on @Published or any other custom property wrapper due to exclusive access rules. (Which makes me wonder if the design of @Published was influenced by the same constraint?)

I think this is not a bug. It's "technically expected" behavior in that @Published is designed for use with SwiftUI which requires the change to be published during willSet.

As a consumer of the API, though, it's quite surprising behavior, and can lead to bugs. In my own code I have several places where I subscribe to a @Published value in a UIViewController and then DispatchQueue.main.async so that I can safely use the full updated object.

I find myself wishing that @Published had a projected value with two publishers on it: willSet and didSet.

Then, instead of doing:

// This is so common I made a convenience method
self.subscriptions += self.lessonState.$selection.sinkMain { [weak self] _ in
	self?.updateSelection() // Relies on self.lessonState, not just .selection
}

I could do:

self.subscriptions += self.lessonState.$selection.didSet.sink { [weak self] _ in
	self?.updateSelection()
}

Which is more explicit, and would not wait for the next run loop.

1 Like

I think you're probably right, unfortunately. It'd be nice to have it confirmed, though. I'd also like to know whether this:

is true. I'd think that if it were, it would be defined in the SwiftUI framework. The fact that it isn't, that's it's defined in Combine, makes it seem like it's suitable for use in normal UIKit apps. Alas, much of @Published's behavior seems to be driven by SwiftUI itself which makes me wish it were defined there.

@Tony_Parker can you confirm any of this? Without more robust documentation, it's really hard to tell what's expected behavior and what isn't. I understand that's quite an undertaking, though.

1 Like

You're right that it is intentional that @Published uses willSet semantics.

The reason it's defined "lower" in the stack was to prevent the need to import a UI framework (SwiftUI) into model code that may not otherwise need it.

4 Likes

Makes sense. Since @Published is available outside of SwiftUI and is likely to be one of the first tools a developer reaches for when building a reactive, publisher-driven application, it would be worth it to document this behavior (someday) when used it's outside of SwiftUI as it can be easily overlooked.

1 Like

Thanks, I'll follow up with our doc people to get this noted (57882355).

3 Likes

@clayellis @Tony_Parker Thanks a lot !

I just made the mistake of using the value of the property instead of the new value returned in the sink closure, then I remembered this post.

Terms of Service

Privacy Policy

Cookie Policy