Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SR-6066] observe NSKeyValueObservedChange oldValue and newValue double optionals incorrectly nil when observing an optional property #3802

Open
bobergj opened this issue Oct 5, 2017 · 20 comments

Comments

@bobergj
Copy link

bobergj commented Oct 5, 2017

Previous ID SR-6066
Radar rdar://problem/39297127
Original Reporter @bobergj
Type Bug
Status Reopened
Resolution

Attachment: Download

Environment

Apple Swift version 5.1.3 (swiftlang-1100.0.282.1 clang-1100.0.33.15)

Additional Detail from JIRA
Votes 1
Component/s Foundation
Labels Bug
Assignee None
Priority Medium

md5: 71e8a3bdae50c0f3c476808e7f3096c9

Issue Description:

Test case:

import Foundation

print("Check what the print output of a double optional is..")
let optionalString: Optional<String> = nil
let optionalOptionalString: Optional<Optional<String>> = optionalString
print(optionalOptionalString)
print("OK, now to the actual test")


class A : NSObject {
    @objc dynamic var anOptionalString: String? = nil
}

func testObserve() {
    
    let a = A()
    let keyPath = \A.anOptionalString
    a.observe(keyPath, options: [.old, .new]) { (objectChanged, observedChange) in
        print("oldValue: \(observedChange.oldValue)")
        print("newValue: \(observedChange.newValue)")

    }
    
    a.anOptionalString = nil
    a.anOptionalString = "abc"
    a.anOptionalString = "def"
}


testObserve()

Output:

Check what the print output of a double optional is..
Optional(nil)
OK, now to the actual test
oldValue: nil
newValue: nil
oldValue: nil
newValue: Optional(Optional("abc"))
oldValue: Optional(Optional("abc"))
newValue: Optional(Optional("def"))

Expected output:

Check what the print output of a double optional is..
Optional(nil)
OK, now to the actual test
oldValue: Optional(nil)
newValue: Optional(nil)
oldValue: Optional(nil)
newValue: Optional(Optional("abc"))
oldValue: Optional(Optional("abc"))
newValue: Optional(Optional("def"))

In https://github.com/apple/swift/blob/master/stdlib/public/SDK/Foundation/NSObject.swift

public struct NSKeyValueObservedChange<Value> {
  ///newValue and oldValue will only be non-nil if .new/.old is passed to   `observe()`. In general, get the most up to date value by accessing it directly on the observed object instead.
  public let newValue: Value?
  public let oldValue: Value?
  ...
}

Since we are passing the options:

[.old, .new]

above, the expectation is that newValue and oldValue aren't nil.

I got bit by this when using observe in a generic context and using a guard statement to bind newValue and oldValue in the beginning of the observe callback closure.

@belkadan
Copy link

belkadan commented Oct 5, 2017

cc @jckarter

@jckarter
Copy link
Member

jckarter commented Oct 5, 2017

This would be @Catfish-Man's wheelhouse.

@tkrajacic
Copy link

Any progress on this?

@belkadan
Copy link

Not sure if there's a Radar specifically for this issue.

@swift-ci create

@tkrajacic
Copy link

This is still broken in Xcode 10 beta and current Swift 4.2 snapshot.

This is a big nuisance since it prevents us from observing optionals. 🙁

extension NSObjectProtocol where Self: NSObject {
    func observe<Value>(_ keyPath: ReferenceWritableKeyPath<Self, Value>, onChange: @escaping (Value) -> ()) -> Disposable {
        let observation = observe(keyPath, options: [.initial, .new]) { _, change in
            // TODO: change.newValue should never be `nil`, but when observing an optional property that's set to `nil`, then change.newValue is `nil` instead of `Optional(nil)`. This is the bug report for this: https://bugs.swift.org/browse/SR-6066
            guard let newValue = change.newValue else { return }
            onChange(newValue)
        }
        return Disposable { observation.invalidate() }
    }
    
    func bind<Value, Target>(_ sourceKeyPath: ReferenceWritableKeyPath<Self, Value>, to target: Target, at targetKeyPath: ReferenceWritableKeyPath<Target, Value>) -> Disposable {
        return observe(sourceKeyPath) { target[keyPath: targetKeyPath] = $0 }
    }
}

final class Disposable {
    let dispose: () -> ()
    init(_ dispose: @escaping () -> ()) {
        self.dispose = dispose
    }
    deinit {
        dispose()
    }
}

Is there a workaround @belkadan?

@belkadan
Copy link

For newValue you can always just ask the object for the current value, so it's not that big of a deal. Also, you could just trust the KVO system when using the .new or .old options, and assume you're never going to have nil for the new or old value except on the initial observation.

        let observation = observe(keyPath, options: [.initial, .new]) { _, change in
            // TODO: change.newValue should never be `nil`, but when observing an optional property that's set to `nil`, then change.newValue is `nil` instead of `Optional(nil)`. Flatten one level of optional to deal with this.
            let newValue = change.newValue ?? nil
            onChange(newValue)
        }

@belkadan
Copy link

(I was going to say "don't ask me; I don't work on KVO", but I guess I do have a track record of finding workarounds…)

@tkrajacic
Copy link

Well, that doesn't work. (Value of optional type 'Value?' must be unwrapped to a value of type 'Value')

I DO want to observe optional properties, so they WILL be nil. And then the newValue MUST be `Optional(nil)`

@tkrajacic
Copy link

   (I was going to say "don't ask me; I don't work on KVO", but I guess I do have a track record of finding workarounds…)

@belkadan I am sorry to bother you, but you do have a track record of being super helpful in that way. I'll write some more bug reports in return… Promise! 😉

@belkadan
Copy link

You've been good about filing bug reports, so thank you.

You should be able to play terrible casting tricks here to get what you need:

let newValue = change.newValue ?? (nil as Any? as! Value)

@tkrajacic
Copy link

I almost peed myself a little when I saw your casting workaround… This works indeed!

And you saved the day YET AGAIN! ❤️

Thank you sooo much! I tried many forms of casting but somehow always missed it 🙂

@swift-ci
Copy link
Contributor

swift-ci commented Aug 6, 2018

Comment by Afonso Graça (JIRA)

Unfortunately this workaround doesn't work for Swift 4.1 🙁 any other voodoo that might do the trick @belkadan?

@swift-ci
Copy link
Contributor

Comment by Zhu Shengqi (JIRA)

@belkadan This bug still exists on Xcode 10.2 with Swift 5 (app store version). Although we can still get the keyPath's value by accessing the keyPath again (which incurs additional performance cost), it annoys me that I have to capture the original object in the closure (which add the unnecessary weak reference).

By the way, the bug was reported in 2017 (and is level medium). Now it's 2019, but the ticket still has no assignee. So I assume the bug won't be fixed in the near future (or just so hard to fix)? No blaming, just want to know if we could have a fix for this issue in Swift 5.2 or 6.

@swift-ci
Copy link
Contributor

Comment by Zhu Shengqi (JIRA)

@belkadan I just created a pull request trying to fix this issue apple/swift#24356 . Hope this would fix the case.

@swift-ci
Copy link
Contributor

Comment by Zhu Shengqi (JIRA)

@bobergj afonso (JIRA User) @belkadan The fix for this issue has been merged into Master branch. See apple/swift#24356 . I'm not sure whether this fix will go into swift 5.1.

@bobergj
Copy link
Author

bobergj commented Feb 5, 2020

This fix didn't go into 5.1.
Good news: it's in the swift 5.2 branch:

$ git branch -r --contains f34ef5d | grep 5.2
  origin/swift-5.2-branch

@gwynne
Copy link
Contributor

gwynne commented Aug 21, 2020

I can confirm this is fixed in 5.2 and 5.3, so I'm marking the issue resolved. @bobergj, if you still see the problem elsewhere, please reopen.

@tkrajacic
Copy link

Not sure this should be considered fixed. When deploying a macOS app to 10.14 this still crashes in release mode even with Swift 5.

extension NSObjectProtocol where Self: NSObject {
    func observe<Value>(_ keyPath: KeyPath<Self, Value>, onChange: @escaping (Value) -> ()) -> Disposable {
        let observation = observe(keyPath, options: [.initial, .new]) { _, change in
            /// works in release mode under 10.14
            // See: https://bugs.swift.org/browse/SR-6066
            let newValue = change.newValue ?? (nil as Any? as! Value)
            
            // works only on 10.15
            //let newValue = change.newValue!
            onChange(newValue)
        }
        return Disposable { observation.invalidate() }
    }
    
    func bind<Value, Target>(_ sourceKeyPath: KeyPath<Self, Value>, to target: Target, at targetKeyPath: ReferenceWritableKeyPath<Target, Value>) -> Disposable {
        return observe(sourceKeyPath) { target[keyPath: targetKeyPath] = $0 }
    }
}

@tkrajacic
Copy link

This might not crash anymore when deployed under 10.15 but it still crashes in release mode on 10.14 when using Swift 5 mode.

Tested in Xcode 12

I have added a simple test app that shows the problem.

crashes when launched under 10.14. runs fine on 10.15

@bobergj
Copy link
Author

bobergj commented Dec 16, 2020

@tkrajacic: That is to be expected since the necessary fix is in the standard library (see apple/swift@f34ef5d#diff-d916f618d7c272de15da731dfc34a377bdf9ae6bb6301692ce3d42848d7c05e8), and the standard library is shipped with the OS, not with your app.

@swift-ci swift-ci transferred this issue from apple/swift-issues Apr 25, 2022
@shahmishal shahmishal transferred this issue from apple/swift May 5, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants