mirror of
https://github.com/apple/swift.git
synced 2026-02-27 18:26:24 +01:00
This is the implementation for https://github.com/swiftlang/swift-evolution/blob/main/proposals/0506-advanced-observation-tracking.md and additionally two bug fixes around termination events. This adds two new entry points for tracking observations. One new one-shot api that has a new options parameter for controlling events and one new continuous form that is a callback version of Observations. Bug fixes: Previously `Observations` had a very small but still present window of opportunity during deinitialization to miss an event and leave the AsyncSequence never emitting a final event but never finishing. Primarily this could occur when a weakly referenced `@Observable` type was deinitialized from another isolation than the observation itself. This current implementation leverages the new options parameter to account for the deinitailization. Both `Observations` and `withObservationTracking` where susceptible to a very small race condition where there was a window of opportunity of a secondary isolation to mutate a tracked property while the setup of the observation was being called. Self isolation mutation during the setup cannot be reported since that would distinctly cause recursive failures in both observation based code but also code like SwiftUI using it so the same isolation must be ignored, however external isolation changes have now been addressed by verifying the tracking lists against the potential "dirty-ness" of a property. This fixes https://github.com/swiftlang/swift/issues/83359.
612 lines
14 KiB
Swift
612 lines
14 KiB
Swift
// REQUIRES: swift_swift_parser, executable_test
|
|
|
|
// RUN: %target-run-simple-swift( -Xfrontend -disable-availability-checking -parse-as-library -enable-experimental-feature Macros -Xfrontend -plugin-path -Xfrontend %swift-plugin-dir)
|
|
|
|
// Run this test via the swift-plugin-server
|
|
// RUN: %target-run-simple-swift( -Xfrontend -disable-availability-checking -parse-as-library -enable-experimental-feature Macros -Xfrontend -external-plugin-path -Xfrontend %swift-plugin-dir#%swift-plugin-server)
|
|
|
|
// REQUIRES: observation
|
|
// REQUIRES: concurrency
|
|
// REQUIRES: objc_interop
|
|
// REQUIRES: swift_feature_Macros
|
|
// UNSUPPORTED: use_os_stdlib
|
|
// UNSUPPORTED: back_deployment_runtime
|
|
|
|
import StdlibUnittest
|
|
import Observation
|
|
|
|
@usableFromInline
|
|
@inline(never)
|
|
func _blackHole<T>(_ value: T) { }
|
|
|
|
@Observable
|
|
class ContainsNothing { }
|
|
|
|
@Observable
|
|
class ContainsWeak {
|
|
weak var obj: AnyObject? = nil
|
|
}
|
|
|
|
@Observable
|
|
public class PublicContainsWeak {
|
|
public weak var obj: AnyObject? = nil
|
|
}
|
|
|
|
@Observable
|
|
class ContainsUnowned {
|
|
unowned var obj: AnyObject? = nil
|
|
}
|
|
|
|
@Observable
|
|
class ContainsIUO {
|
|
var obj: Int! = nil
|
|
}
|
|
|
|
class NonObservable {
|
|
|
|
}
|
|
|
|
@Observable
|
|
class InheritsFromNonObservable: NonObservable {
|
|
|
|
}
|
|
|
|
protocol NonObservableProtocol {
|
|
|
|
}
|
|
|
|
@Observable
|
|
class ConformsToNonObservableProtocol: NonObservableProtocol {
|
|
|
|
}
|
|
|
|
struct NonObservableContainer {
|
|
@Observable
|
|
class ObservableContents {
|
|
var field: Int = 3
|
|
}
|
|
}
|
|
|
|
@Observable
|
|
final class SendableClass: Sendable {
|
|
var field: Int = 3
|
|
}
|
|
|
|
@Observable
|
|
class CodableClass: Codable {
|
|
var field: Int = 3
|
|
}
|
|
|
|
@Observable
|
|
final class HashableClass {
|
|
var field: Int = 3
|
|
}
|
|
|
|
extension HashableClass: Hashable {
|
|
static func == (lhs: HashableClass, rhs: HashableClass) -> Bool {
|
|
lhs.field == rhs.field
|
|
}
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(field)
|
|
}
|
|
}
|
|
|
|
@Observable
|
|
class ImplementsAccessAndMutation {
|
|
var field = 3
|
|
let accessCalled: (PartialKeyPath<ImplementsAccessAndMutation>) -> Void
|
|
let withMutationCalled: (PartialKeyPath<ImplementsAccessAndMutation>) -> Void
|
|
|
|
init(accessCalled: @escaping (PartialKeyPath<ImplementsAccessAndMutation>) -> Void, withMutationCalled: @escaping (PartialKeyPath<ImplementsAccessAndMutation>) -> Void) {
|
|
self.accessCalled = accessCalled
|
|
self.withMutationCalled = withMutationCalled
|
|
}
|
|
|
|
internal func access<Member>(
|
|
keyPath: KeyPath<ImplementsAccessAndMutation , Member>
|
|
) {
|
|
accessCalled(keyPath)
|
|
_$observationRegistrar.access(self, keyPath: keyPath)
|
|
}
|
|
|
|
internal func withMutation<Member, T>(
|
|
keyPath: KeyPath<ImplementsAccessAndMutation , Member>,
|
|
_ mutation: () throws -> T
|
|
) rethrows -> T {
|
|
withMutationCalled(keyPath)
|
|
return try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
|
|
}
|
|
}
|
|
|
|
@Observable
|
|
class HasIgnoredProperty {
|
|
var field = 3
|
|
@ObservationIgnored var ignored = 4
|
|
}
|
|
|
|
@Observable
|
|
class Entity {
|
|
var age: Int = 0
|
|
}
|
|
|
|
@Observable
|
|
class Person : Entity {
|
|
var firstName = ""
|
|
var lastName = ""
|
|
|
|
var friends = [Person]()
|
|
|
|
var fullName: String { firstName + " " + lastName }
|
|
}
|
|
|
|
@Observable
|
|
class MiddleNamePerson: Person {
|
|
var middleName = ""
|
|
|
|
override var fullName: String { firstName + " " + middleName + " " + lastName }
|
|
}
|
|
|
|
@Observable
|
|
class IsolatedClass {
|
|
@MainActor var test = "hello"
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
class IsolatedInstance {
|
|
var test = "hello"
|
|
}
|
|
|
|
@Observable
|
|
class IgnoredComputed {
|
|
@ObservationIgnored
|
|
var message: String { "hello" }
|
|
}
|
|
|
|
@Observable
|
|
class ClassHasExistingConformance: Observable { }
|
|
|
|
protocol Intermediary: Observable { }
|
|
|
|
@Observable
|
|
class HasIntermediaryConformance: Intermediary { }
|
|
|
|
class CapturedState<State>: @unchecked Sendable {
|
|
var state: State
|
|
|
|
init(state: State) {
|
|
self.state = state
|
|
}
|
|
}
|
|
|
|
@Observable
|
|
class RecursiveInner {
|
|
var value = "prefix"
|
|
}
|
|
|
|
@Observable
|
|
class GenericClass<T> {
|
|
var value = 3
|
|
}
|
|
|
|
struct StructParent {
|
|
@Observable
|
|
class NestedGenericClass<T> {
|
|
var value = 3
|
|
}
|
|
}
|
|
|
|
struct GenericStructParent<T> {
|
|
@Observable
|
|
class NestedClass {
|
|
var value = 3
|
|
}
|
|
}
|
|
|
|
class ClassParent {
|
|
@Observable
|
|
class NestedGenericClass<T> {
|
|
var value = 3
|
|
}
|
|
}
|
|
|
|
class GenericClassParent<T> {
|
|
@Observable
|
|
class NestedClass {
|
|
var value = 3
|
|
}
|
|
}
|
|
|
|
@Observable
|
|
class RecursiveOuter {
|
|
var inner = RecursiveInner()
|
|
var value = "prefix"
|
|
@ObservationIgnored var innerEventCount = 0
|
|
@ObservationIgnored var outerEventCount = 0
|
|
|
|
func recursiveTrackingCalls() {
|
|
withObservationTracking({
|
|
let _ = value
|
|
_ = withObservationTracking({
|
|
inner.value
|
|
}, onChange: {
|
|
self.innerEventCount += 1
|
|
})
|
|
}, onChange: {
|
|
self.outerEventCount += 1
|
|
})
|
|
}
|
|
}
|
|
|
|
@Observable
|
|
#if FOO
|
|
@available(SwiftStdlib 5.9, *)
|
|
#elseif BAR
|
|
@available(SwiftStdlib 5.9, *)
|
|
#else
|
|
#endif
|
|
class GuardedAvailability {
|
|
}
|
|
|
|
@Observable class TestASTScopeLCA {
|
|
// Make sure ASTScope unqualified lookup can find local variables
|
|
// inside initial values with closures when accessor macros are
|
|
// involved.
|
|
var state : Bool = {
|
|
let value = true
|
|
return value
|
|
}()
|
|
}
|
|
|
|
@Observable class Parent {
|
|
class Nested {}
|
|
}
|
|
|
|
extension Parent.Nested {}
|
|
|
|
struct CowContainer {
|
|
final class Contents { }
|
|
|
|
var contents = Contents()
|
|
|
|
mutating func mutate() {
|
|
if !isKnownUniquelyReferenced(&contents) {
|
|
contents = Contents()
|
|
}
|
|
}
|
|
|
|
var id: ObjectIdentifier {
|
|
ObjectIdentifier(contents)
|
|
}
|
|
}
|
|
|
|
|
|
@Observable
|
|
final class CowTest {
|
|
var container = CowContainer()
|
|
}
|
|
|
|
@main
|
|
struct Validator {
|
|
@MainActor
|
|
static func main() async {
|
|
|
|
|
|
let suite = TestSuite("Observable")
|
|
|
|
suite.test("only instantiate") {
|
|
let test = MiddleNamePerson()
|
|
}
|
|
|
|
suite.test("unobserved value changes") {
|
|
let test = MiddleNamePerson()
|
|
for i in 0..<100 {
|
|
test.firstName = "\(i)"
|
|
}
|
|
}
|
|
|
|
suite.test("tracking changes") {
|
|
let changed = CapturedState(state: false)
|
|
|
|
let test = MiddleNamePerson()
|
|
withObservationTracking {
|
|
_blackHole(test.firstName)
|
|
} onChange: {
|
|
changed.state = true
|
|
}
|
|
|
|
test.firstName = "c"
|
|
expectEqual(changed.state, true)
|
|
changed.state = false
|
|
test.firstName = "c"
|
|
expectEqual(changed.state, false)
|
|
}
|
|
|
|
suite.test("conformance") {
|
|
func testConformance<O: Observable>(_ o: O) -> Bool {
|
|
return true
|
|
}
|
|
|
|
func testConformance<O>(_ o: O) -> Bool {
|
|
return false
|
|
}
|
|
|
|
let test = Person()
|
|
expectEqual(testConformance(test), true)
|
|
}
|
|
|
|
suite.test("tracking nonchanged") {
|
|
let changed = CapturedState(state: false)
|
|
|
|
let test = MiddleNamePerson()
|
|
withObservationTracking {
|
|
_blackHole(test.lastName)
|
|
} onChange: {
|
|
changed.state = true
|
|
}
|
|
|
|
test.firstName = "c"
|
|
expectEqual(changed.state, false)
|
|
}
|
|
|
|
suite.test("tracking computed") {
|
|
let changed = CapturedState(state: false)
|
|
|
|
let test = MiddleNamePerson()
|
|
withObservationTracking {
|
|
_blackHole(test.fullName)
|
|
} onChange: {
|
|
changed.state = true
|
|
}
|
|
|
|
test.middleName = "c"
|
|
expectEqual(changed.state, true)
|
|
changed.state = false
|
|
test.middleName = "c"
|
|
expectEqual(changed.state, false)
|
|
}
|
|
|
|
suite.test("graph changes") {
|
|
let changed = CapturedState(state: false)
|
|
|
|
let test = MiddleNamePerson()
|
|
let friend = MiddleNamePerson()
|
|
test.friends.append(friend)
|
|
withObservationTracking {
|
|
_blackHole(test.friends.first?.fullName)
|
|
} onChange: {
|
|
changed.state = true
|
|
}
|
|
|
|
test.middleName = "c"
|
|
expectEqual(changed.state, false)
|
|
friend.middleName = "c"
|
|
expectEqual(changed.state, true)
|
|
}
|
|
|
|
suite.test("nesting") {
|
|
let changedOuter = CapturedState(state: false)
|
|
let changedInner = CapturedState(state: false)
|
|
|
|
let test = MiddleNamePerson()
|
|
withObservationTracking {
|
|
withObservationTracking {
|
|
_blackHole(test.firstName)
|
|
} onChange: {
|
|
changedInner.state = true
|
|
}
|
|
} onChange: {
|
|
changedOuter.state = true
|
|
}
|
|
|
|
test.firstName = "c"
|
|
expectEqual(changedInner.state, true)
|
|
expectEqual(changedOuter.state, true)
|
|
changedOuter.state = false
|
|
test.firstName = "c"
|
|
expectEqual(changedOuter.state, false)
|
|
}
|
|
|
|
suite.test("access and mutation") {
|
|
let accessKeyPath = CapturedState<PartialKeyPath<ImplementsAccessAndMutation>?>(state: nil)
|
|
let mutationKeyPath = CapturedState<PartialKeyPath<ImplementsAccessAndMutation>?>(state: nil)
|
|
let test = ImplementsAccessAndMutation { keyPath in
|
|
accessKeyPath.state = keyPath
|
|
} withMutationCalled: { keyPath in
|
|
mutationKeyPath.state = keyPath
|
|
}
|
|
|
|
expectEqual(accessKeyPath.state, nil)
|
|
_blackHole(test.field)
|
|
expectEqual(accessKeyPath.state, \.field)
|
|
expectEqual(mutationKeyPath.state, nil)
|
|
accessKeyPath.state = nil
|
|
test.field = 123
|
|
expectEqual(accessKeyPath.state, nil)
|
|
expectEqual(mutationKeyPath.state, \.field)
|
|
}
|
|
|
|
suite.test("ignores no change") {
|
|
let changed = CapturedState(state: false)
|
|
|
|
let test = HasIgnoredProperty()
|
|
withObservationTracking {
|
|
_blackHole(test.ignored)
|
|
} onChange: {
|
|
changed.state = true
|
|
}
|
|
|
|
test.ignored = 122112
|
|
expectEqual(changed.state, false)
|
|
changed.state = false
|
|
test.field = 3429
|
|
expectEqual(changed.state, false)
|
|
}
|
|
|
|
suite.test("ignores change") {
|
|
let changed = CapturedState(state: false)
|
|
|
|
let test = HasIgnoredProperty()
|
|
withObservationTracking {
|
|
_blackHole(test.ignored)
|
|
_blackHole(test.field)
|
|
} onChange: {
|
|
changed.state = true
|
|
}
|
|
|
|
test.ignored = 122112
|
|
expectEqual(changed.state, false)
|
|
changed.state = false
|
|
test.field = 3429
|
|
expectEqual(changed.state, true)
|
|
}
|
|
|
|
suite.test("isolated class") { @MainActor in
|
|
let changed = CapturedState(state: false)
|
|
|
|
let test = IsolatedClass()
|
|
withObservationTracking {
|
|
_blackHole(test.test)
|
|
} onChange: {
|
|
changed.state = true
|
|
}
|
|
|
|
test.test = "c"
|
|
expectEqual(changed.state, true)
|
|
changed.state = false
|
|
test.test = "c"
|
|
expectEqual(changed.state, false)
|
|
}
|
|
|
|
suite.test("recursive tracking inner then outer") {
|
|
let obj = RecursiveOuter()
|
|
obj.recursiveTrackingCalls()
|
|
obj.inner.value = "test"
|
|
expectEqual(obj.innerEventCount, 1)
|
|
expectEqual(obj.outerEventCount, 1)
|
|
obj.recursiveTrackingCalls()
|
|
obj.value = "test"
|
|
expectEqual(obj.innerEventCount, 1)
|
|
expectEqual(obj.outerEventCount, 2)
|
|
}
|
|
|
|
suite.test("recursive tracking outer then inner") {
|
|
let obj = RecursiveOuter()
|
|
obj.recursiveTrackingCalls()
|
|
obj.value = "test"
|
|
expectEqual(obj.innerEventCount, 0)
|
|
expectEqual(obj.outerEventCount, 1)
|
|
obj.recursiveTrackingCalls()
|
|
obj.inner.value = "test"
|
|
expectEqual(obj.innerEventCount, 2)
|
|
expectEqual(obj.outerEventCount, 2)
|
|
}
|
|
|
|
suite.test("validate copy on write semantics") {
|
|
let subject = CowTest()
|
|
let startId = subject.container.id
|
|
expectEqual(subject.container.id, startId)
|
|
subject.container.mutate()
|
|
expectEqual(subject.container.id, startId)
|
|
}
|
|
|
|
suite.test("tracking changes willSet option") {
|
|
let changed = CapturedState(state: false)
|
|
|
|
let test = MiddleNamePerson()
|
|
withObservationTracking(options: .willSet) {
|
|
_blackHole(test.firstName)
|
|
} onChange: { _ in
|
|
changed.state = true
|
|
}
|
|
|
|
test.firstName = "c"
|
|
expectEqual(changed.state, true)
|
|
changed.state = false
|
|
test.firstName = "c"
|
|
expectEqual(changed.state, false)
|
|
}
|
|
|
|
suite.test("tracking changes didSet option") {
|
|
let changed = CapturedState(state: false)
|
|
|
|
let test = MiddleNamePerson()
|
|
withObservationTracking(options: .didSet) {
|
|
_blackHole(test.firstName)
|
|
} onChange: { _ in
|
|
changed.state = true
|
|
}
|
|
|
|
test.firstName = "c"
|
|
expectEqual(changed.state, true)
|
|
changed.state = false
|
|
test.firstName = "c"
|
|
expectEqual(changed.state, false)
|
|
}
|
|
|
|
suite.test("tracking changes willSet & didSet option") {
|
|
let changed = CapturedState(state: 0)
|
|
|
|
let test = MiddleNamePerson()
|
|
withObservationTracking(options: [.willSet, .didSet]) {
|
|
_blackHole(test.firstName)
|
|
} onChange: { _ in
|
|
changed.state += 1
|
|
}
|
|
|
|
test.firstName = "c"
|
|
expectEqual(changed.state, 2) // an event is triggered for both the willSet and the didSet
|
|
changed.state = 0
|
|
test.firstName = "c"
|
|
expectEqual(changed.state, 0)
|
|
}
|
|
|
|
suite.test("tracking changes deinit option") {
|
|
let changed = CapturedState(state: false)
|
|
|
|
var test: MiddleNamePerson? = MiddleNamePerson()
|
|
withObservationTracking(options: .deinit) {
|
|
_blackHole(test?.firstName)
|
|
} onChange: { _ in
|
|
changed.state = true
|
|
}
|
|
|
|
test?.firstName = "c"
|
|
expectEqual(changed.state, false)
|
|
changed.state = false
|
|
test?.firstName = "c"
|
|
expectEqual(changed.state, false)
|
|
test = nil
|
|
expectEqual(changed.state, true)
|
|
}
|
|
|
|
suite.test("continuous tracking") {
|
|
let changed = CapturedState(state: 0)
|
|
let test: MiddleNamePerson = MiddleNamePerson()
|
|
let token = withContinuousObservation(options: .didSet) { _ in
|
|
_blackHole(test.firstName)
|
|
changed.state += 1
|
|
}
|
|
// the sleeps here are intended to be a simulation of returning to the main run loop
|
|
try! await Task.sleep(for: .seconds(0.1))
|
|
expectEqual(changed.state, 1)
|
|
test.firstName = "c"
|
|
try! await Task.sleep(for: .seconds(0.1))
|
|
expectEqual(changed.state, 2)
|
|
test.firstName = "d"
|
|
try! await Task.sleep(for: .seconds(0.1))
|
|
expectEqual(changed.state, 3)
|
|
token.cancel()
|
|
try! await Task.sleep(for: .seconds(0.1)) // ensure a small grace w.r.t. cancellation
|
|
test.firstName = "e"
|
|
try! await Task.sleep(for: .seconds(0.1))
|
|
expectEqual(changed.state, 3)
|
|
}
|
|
|
|
await runAllTestsAsync()
|
|
}
|
|
}
|
|
|
|
|