[Swift 5.2] Struct + Property Wrapper didSet defect?

Hi all,

Something I noticed in Swift 5.2 in the latest betas, and the latest snapshots, when you use a property wrapper for a struct, and you set a value inside the struct, a didSet does not get called. For example:

import Combine

struct MyStruct {
    var details: String
}

class MyClass {

    @Published var myStruct = MyStruct(details: "Test") {
        didSet {
            print("My struct was set")
        }
    }

}

let myClass = MyClass()

myClass.myStruct.details = "test" // This should print "My struct was set", but doesn't.
myClass.myStruct = MyStruct(details: "test") // This continues to print "My struct was set".

Is this change in behaviour expected? Seems extremely weird to me, and is causing a few breaking defects inside our codebase.

Thanks,

Rod

1 Like

This might be SR-12178, I have a fix for it here. If not, then it might be a different bug.

You aren't the first one to notice this. There is a bug report for the exact issue here: [SR-12089] Swift 5.2 snapshot: didSet not called · Issue #54525 · apple/swift · GitHub and another report about it on the forums here: Property wrapper observer not firing

Unfortunately it doesn't look like its been picked up yet and the closer to the 5.2 release we get the less chance it will get fixed. I'm tagging in @beccadax as he was the one who created the internal Apple tracking ticket.

That looks like the one I mentioned before - I have linked it to my fix! And yeah, it might not make 11.4 but might be available in a point release (like 11.4.1) depending on when my PR is merged.

2 Likes

This is a pretty glaring bug that will blindside anyone who’s relying on these semantics. The only way I noticed is via unit tests failing. I sure hope this makes it into 11.4.

How can we get an official Apple answer about this being addressed for Xcode 11.4? It seems like a regression in observability semantics warrants delaying Xcode 11.4 until it is fixed.

3 Likes

Don't read too much into that—I create nearly all of the radars. :slight_smile:

Yes, looking at SIL dumps, I think the modify accessor is the problem in all of these bugs (and I thought so before I looked at your PR).

I don't know when this bug will be fixed (i.e. this is not an "official Apple answer"), but for the @Published examples at least, there's a straightforward workaround: subscribe to the property's projected value (the matching $ property), which is a Publisher that publishes changes to the property, and tie your didSet logic to that.

1 Like

Also experiencing this issue when using += with a property wrapped int.

Using the released version of Xcode 11.4
This bug completely broke a piece of our app.

Switching it to value = value + 1 works but doesn't seem ideal.

Example code:

import Combine

class MyClass {

    @Published var value = 4 {
        didSet {
            print("Value was set to \(value)")
        }
    }

}

let myClass = MyClass()

myClass.value = 5 // This prints "Value was set to 5"
myClass.value += 1 // This should print "Value was set to 6", but doesn't.
myClass.value = myClass.value + 1 // This prints "Value was set to 7"
3 Likes

Here's a minimum case.
This bug will occur with mutating operations.

@propertyWrapper
struct A<V> {
    var wrappedValue: V
}

class C {
    @A var x = 0 {
        didSet { print(x) }
    }
}

extension Int {
    mutating func f() {
        self = 1
    }
}

C().x.f()      // doesn't print
C().x += 2     // doesn't print
C().x = 3      // prints '3'
3 Likes

Same here! I cannot believe they knew about the bug months ago and released Swift 5.2 without the fix. It should be considered not a regular medium-priority bug, but critical functionality regression!

2 Likes

This is clearly an example of _modify being used now instead of get and set. That it's the reason why += does not work but value = value + 1 does.

This is now fixed on master and I have opened a PR to pull the fix into 5.2 as well.

7 Likes

I can confirm the fix has made its way to Xcode 11.5 beta.

9 Likes

I had to use a logical negation instead of toggle() if I wanted to store the new boolean value of a SwiftUI @Published property in UserDefaults.

With the new Swift 5.2.4 in Xcode 11.5 beta I confirm you can use toggle() again.

class Settings: ObservableObject {
    @Published var mutedAudio: Bool = UserDefaults.standard.bool(forKey: "mutedAudio") {  
        didSet { UserDefaults.standard.set(self.mutedAudio, forKey: "mutedAudio") }  
    } 
}

Button(action: {  
    self.settings.mutedAudio.toggle() 
    // self.settings.mutedAudio = !self.settings.mutedAudio // workaround in iOS 13.4
}) {  
    Image(systemName: settings.mutedAudio ? "speaker.slash.fill" : "speaker.2.fill").resizable().frame(width: 24, height: 24)  
}

I'm not sure if @State was treated differently from @Published property wrapper.
I tested the following code both with @State and @Published in Xcode 11.5 beta (11N605c), but only observers (willSet, didSet) for @Published were triggered.
And I'm also not sure whether this is related to SwiftUI implementation detail or the same bug related to this post?
Maybe I missed something obvious.

struct ContentView: View {
  @State
  private var isOn = false {
    willSet {
      print("@State isOn willSet:", isOn) // not working
    }
    didSet {
      print("@State isOn didSet:", isOn) // not working
    }
  }
  @ObservedObject
  private var store = ContentStore()
  var body: some View {
    Form {
      Toggle(isOn: $isOn) {
        Text("The toggle for @State isOn")
      }
      Toggle(isOn: $store.isOn) {
        Text("The toggle for @Published isOn")
      }
    }
  }
}

final class ContentStore: ObservableObject {
  @Published
  var isOn = false {
    willSet {
      print("@Published isOn willSet:", isOn) // working
    }
    didSet {
      print("@Published isOn didSet:", isOn) // working
    }
  }
}
1 Like

Hi @surashsrijan,

I'm just curious did that fix change the semantics of the didSet operator to fire even before initialisation has finished?

Something I noticed:


class MyClass: MySuperClass {

    private var itemHasChanged = false

    @Published private var myPublishedItem: Item? {
        didSet {
            itemHasChanged = true // This shouldn't fire in the initialiser, but does?
        }
    }

    init(item: Item?) {
        self.item = item
        super.init()
    }
}

If the value is marked as @Published, this fires in the initialiser. If it isn't marked with a property wrapper, it doesn't. Is this expected?

1 Like

Could you post a full reproducer?

Copy and paste this into a playground:

import Combine

class MyClass {

    @Published var publishedInt = 5 {
        didSet { print("Published int updated") }
    }

    var unpublishedInt = 5 {
        didSet { print("Unpublished int updated") }
    }

    init(publishedInt: Int, unpublishedInt: Int) {
        self.publishedInt = publishedInt
        self.unpublishedInt = unpublishedInt
    }

}

let example = MyClass(publishedInt: 4, unpublishedInt: 4)

When run, the published print is performed, but the unpublished print is not.

1 Like

It seems that for @Published variables, the default initialization counts as a setting (where you initialize it to '5'), thus when you change the value in the init method, the value changes and you invoke the didSet. The second unpublishedInt follows the standard Swift convention where init initializes the variable, no matter if it was previously initialized in a initial value statement. I guess the question is, "does a change in a variable's value in an init method count as a change for @Published or not?" Note that if you remove the initialization of publishedInt, no printout results.

The following shows consistent behavior between the two, but allows for default initialization to 5:

import Combine

class MyClass {

	@Published var publishedInt : Int {
		didSet { print("Published int updated") }
	}

	var unpublishedInt : Int {
		didSet { print("Unpublished int updated") }
	}

	init(publishedInt: Int=5, unpublishedInt: Int=5) {
		self.publishedInt = publishedInt
		self.unpublishedInt = unpublishedInt
	}

}

let example = MyClass(publishedInt: 4, unpublishedInt: 4)
1 Like

This pitch seems closely related and AFAICT explains your observed current behaviour: Allow Property Wrappers with Multiple Arguments to Defer Initialization when wrappedValue is not Specified

Namely, wrapped properties get immediately initialised at their declaration if a default value is provided there; assigning them in the initialiser then counts as reassignment and that's why didSet gets (IMO somewhat unexpectedly) called.

If the pitched proposal were to be adopted then neither didSet would get called in your example.