[Swift 5.2] Struct + Property Wrapper didSet defect?

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"
4 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!

1 Like

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.

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

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)

This pitch seems closely related and AFAICT explains your observed current behaviour: Proposal: 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.

Absolutely, and this way of working is obviously correct from the POV of the property wrapper, but the didSet behaviour (in the context of the containing object) appears to be a regression. Unless I’m much mistaken, the property wrappers proposal didn’t suggest that their didSet semantics would vary from the default. Indeed, the point of property wrappers was to wrap additional behaviour without causing an adjustment in standard property behavior.

This causes much bigger problems if a referenced property in the didSet is not yet constructed as part of your initializer. Part of the reason didSet is not called in initialisers, but is called in subclass initialisers, is because of the inability to reason during initialization about the state of the containing object.

Yeah, I can reproduce it on 5.3 (Xcode 12 beta) but it behaves the same in 5.1 as well. Does it behave differently for you in an older version of the compiler?

Yeah, seems to be the case. That's unfortunate :disappointed:

My simple test shows @​State var willSet()/didSet() works now in Xcode 12 beta 4:

​import SwiftUI

struct AtStateDidSetWorkOrNot: View {
    @State private var aStateVar = 100 {
        willSet(newValue) {
            print("aStateVar willSet to \(newValue)")
        }
        didSet {
            print("aStateVar didSet from \(oldValue) to \(aStateVar)")
        }
    }

    var body: some View {
        VStack {
            Text("Trepidation #\(aStateVar)")
            Button {
                aStateVar = Int.random(in: 0...200)
                print("aStateVar = \(aStateVar)")
            }
            label: {
                Text("Change aStateVar")
            }
        }
    }
}

struct AtStateDidSetWorkOrNot_Previews: PreviewProvider {
    static var previews: some View {
        AtStateDidSetWorkOrNot()
    }
}
1 Like
Terms of Service

Privacy Policy

Cookie Policy