Is this a bug in @Published?

That is a fair question, but it would be strange to call this a didSet while the publisher fires before the property is truly set.

Can you emit on main queue and see if that changes anything?

I don't know anything about Combine, but this creates an infinite loop which is... fun..

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

As it should :sweat_smile:

That is expected, it‘s like if you mutate a property during didSet observer. (Not sure if the compiler allows it though.)

Ah and sorry for confusion, I had closure and object values flipped in my head. However my previous statement about a didSet event remains.

That actually does resolve the issue (I can't control what queue @Published publishes on, but I can control what I receive on):

This change:

var valuePublisher: AnyPublisher<Int, Never> {
    $value.receive(on: DispatchQueue.main).eraseToAnyPublisher()
}

Produces:

closure value: 42
object value: 42

But it drops the initial value which is likely due to the causes outlined in Combine `.receive(on: RunLoop.main)` loses sent value. How can I make it work?

But now I'm curious because I'm struggling to understand why receiving on the main queue would alter that behavior? I think I need to re-read that thread.

Are you testing it with a beta device like mentioned in the thread? If not then it works because internally it still uses async which causes the publisher to emit after the property is truly set. However this is still not didSet semantic.

:man_facepalming:t2: I'm not

I'll have to install 10.15.2 beta 3 on a volume and test again. (I assume that beta is included in the list of "associated releases"?)

If anyone else is curious, has the beta installed, and wouldn't mind — will you run the original code in the first post and post the output?

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.

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

5 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
Terms of Service

Privacy Policy

Cookie Policy