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
3 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.

3 Likes

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.

7 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.

2 Likes

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.

1 Like

Well I watched all the WWDC's and read a lot of docs on Combine and didn't see any mention that "@Published is intended for SwiftUI", but here you say that it is. Only now, after reading this topic, I assume that we should probably not rely on @Published + sink in UIKit, and better look for something else like PassthourghSubject.

I must say that this is a frustrating experience of exploring the API.

1 Like

I've actually written my own LatePublished wrapper that sets the wrapped property before calling the observers, based on OpenCombine's implementation of Published. If that implementation is correct, I don't see why Published works like that, as calling the observers before setting the property isn't necessary for SwiftUI, as that relies on the separate objectWillChange publisher. Exactly the same publisher that updates the property and then calls the observers works fine in SwiftUI.

3 Likes

Just ran into this and causing me so many headaches. I was about to write my own wrapper. Reading through this whole thread it seems like @Published needs to be moved to SwiftUI and a new wrapper for general use needs to be developed

It is rather unfortunate, and given that it isn't well-documented, it is bound to be a "gotcha" for a while to come. It would be nice to be able to choose the semantics à la:

@Published(on: .didSet) var ...

with the options .didSet and .willSet (defaulting to .willSet.)

4 Likes

The more I think about this, the more I want it. @Tony_Parker, has something like this ever been considered before? Any chance it would be considered? (If Combine were part of Swift Evolution, I'd pitch it, but asking you was the next best thing I could think of.)

3 Likes

A few notes:

  • I mentioned it before, but to repeat since it was raised again just now: @Published is in Combine (and reexported from Combine at higher level in Foundation) and not SwiftUI because the idea is to use it in model objects where you probably aren't doing an import SwiftUI but just import Foundation.
  • I believe the documentation issue is resolved now (Apple Developer Documentation).
  • We (and SwiftUI) chose willChange because it has some advantages over didChange:
    • It enables snapshotting the state of the object (since you have access to both the old and new value, via the current value of the property and the value you receive). This is important for SwiftUI's performance, but has other applications.
    • "will" notifications are easier to coalesce at a low level, because you can skip further notifications until some other event (e.g., a run loop spin). Combine makes this coalescing straightforward with operators like removeDuplicates, although I do think we need a few more grouping operators to help with things like run loop integration.
    • It's easier to make the mistake of getting a half-modified object with did, because one change is finished but another may not be done yet.

All of that said, I don't want to diminish the use case of firing on didSet. Let's start with why it's important that the property itself reflect the new value vs using the value sent through the Publisher. Could I get some more info on how it's being used?

16 Likes

I don't think the issue is the willChange notification that SwiftUI uses but rather the difference between when the wrapped value is updated vs. when the actual publisher is fired (at least if the OpenCombine implementation is accurate in that respect). This issue shows up when, in a Combine stream from the publisher, the property is access. In that case, the stream has the new value while the property has the old value, leaving us with the unexpected behavior.

2 Likes