diff --git a/Package.resolved b/Package.resolved index 20ca07d37..cb37a0743 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f229c995beae82593994793abdd0fb2239bd44bded0c9672a73e95e1a2baecf5", + "originHash" : "2432e3fbb351913595eab925a316b1813a5e4b112660f54286c5b94f893e6fd1", "pins" : [ { "identity" : "darwinprivateframeworks", @@ -7,7 +7,7 @@ "location" : "https://github.com/OpenSwiftUIProject/DarwinPrivateFrameworks.git", "state" : { "branch" : "main", - "revision" : "4aa30d65aae91b8cd3aa7e6b5910d281a77e9af0" + "revision" : "00977e88d14f98c248bf1c8370855ad6ecc35977" } }, { @@ -16,7 +16,7 @@ "location" : "https://github.com/OpenSwiftUIProject/OpenAttributeGraph", "state" : { "branch" : "main", - "revision" : "cc5a58ccf7720bf096fc487c2a518ae1c5139c4d" + "revision" : "d0a060c05377350dea911601dc5416738c000541" } }, { @@ -28,6 +28,15 @@ "revision" : "1a1b9ad092ed373a3c94df7b589e3e099da93699" } }, + { + "identity" : "openobservation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenSwiftUIProject/OpenObservation", + "state" : { + "branch" : "main", + "revision" : "1eb29e56b75f86cf7ee1593e315bafc1ab07be3d" + } + }, { "identity" : "openrenderbox", "kind" : "remoteSourceControl", @@ -46,6 +55,15 @@ "version" : "1.0.3" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + }, { "identity" : "symbollocator", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 59b4a24d3..fb48c53e3 100644 --- a/Package.swift +++ b/Package.swift @@ -224,6 +224,7 @@ let openSwiftUICoreTarget = Target.target( .product(name: "OpenQuartzCoreShims", package: "OpenCoreGraphics"), .product(name: "OpenAttributeGraphShims", package: "OpenAttributeGraph"), .product(name: "OpenRenderBoxShims", package: "OpenRenderBox"), + .product(name: "OpenObservation", package: "OpenObservation"), ] + (swiftUIRenderCondition && symbolLocatorCondition ? ["OpenSwiftUISymbolDualTestsSupport"] : []), cSettings: sharedCSettings, cxxSettings: sharedCxxSettings, @@ -542,6 +543,7 @@ if useLocalDeps { .package(path: "../OpenCoreGraphics"), .package(path: "../OpenAttributeGraph"), .package(path: "../OpenRenderBox"), + .package(path: "../OpenObservation"), ] if attributeGraphCondition || renderBoxCondition || linkCoreUI { dependencies.append(.package(path: "../DarwinPrivateFrameworks")) @@ -553,6 +555,7 @@ if useLocalDeps { // FIXME: on Linux platform: OG contains unsafe build flags which prevents us using version dependency .package(url: "https://github.com/OpenSwiftUIProject/OpenAttributeGraph", branch: "main"), .package(url: "https://github.com/OpenSwiftUIProject/OpenRenderBox", branch: "main"), + .package(url: "https://github.com/OpenSwiftUIProject/OpenObservation", branch: "main"), ] if attributeGraphCondition || renderBoxCondition || linkCoreUI { dependencies.append(.package(url: "https://github.com/OpenSwiftUIProject/DarwinPrivateFrameworks.git", branch: "main")) diff --git a/Scripts/CI/darwin_setup_build.sh b/Scripts/CI/darwin_setup_build.sh index 8a362c3d3..6662ab251 100755 --- a/Scripts/CI/darwin_setup_build.sh +++ b/Scripts/CI/darwin_setup_build.sh @@ -11,4 +11,5 @@ cd $REPO_ROOT Scripts/CI/opencoregraphics_setup.sh Scripts/CI/openattributegraph_setup.sh Scripts/CI/openrenderbox_setup.sh +Scripts/CI/openobservation_setup.sh Scripts/CI/framework_setup.sh diff --git a/Scripts/CI/openobservation_setup.sh b/Scripts/CI/openobservation_setup.sh new file mode 100755 index 000000000..a9548469e --- /dev/null +++ b/Scripts/CI/openobservation_setup.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# A `realpath` alternative using the default C implementation. +filepath() { + [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}" +} + +REPO_ROOT="$(dirname $(dirname $(dirname $(filepath $0))))" + +clone_checkout_openobservation() { + cd $REPO_ROOT + revision=$(Scripts/CI/get_revision.sh openobservation) + cd .. + if [ ! -d OpenObservation ]; then + gh repo clone OpenSwiftUIProject/OpenObservation + cd OpenObservation + else + echo "OpenObservation already exists, skipping clone." + cd OpenObservation + git fetch --all --quiet + git stash --quiet || true + git reset --hard --quiet origin/main + fi + git checkout --quiet $revision +} + +clone_checkout_openobservation \ No newline at end of file diff --git a/Sources/OpenSwiftUI/OpenGraph/Attribute/AnyAttributeFix.swift b/Sources/OpenSwiftUI/Util/AnyAttributeFix.swift similarity index 96% rename from Sources/OpenSwiftUI/OpenGraph/Attribute/AnyAttributeFix.swift rename to Sources/OpenSwiftUI/Util/AnyAttributeFix.swift index a58025568..158949cbb 100644 --- a/Sources/OpenSwiftUI/OpenGraph/Attribute/AnyAttributeFix.swift +++ b/Sources/OpenSwiftUI/Util/AnyAttributeFix.swift @@ -33,6 +33,14 @@ extension AnyAttribute { package func createIndirect() -> AnyAttribute { preconditionFailure("#39") } + + package var subgraph: Subgraph { + preconditionFailure("#39") + } + + package var subgraph2: Subgraph? { + preconditionFailure("#39") + } } extension AnyAttribute { diff --git a/Sources/OpenSwiftUICore/Data/Observation/ObservationUtil.swift b/Sources/OpenSwiftUICore/Data/Observation/ObservationUtil.swift new file mode 100644 index 000000000..4d3bfd444 --- /dev/null +++ b/Sources/OpenSwiftUICore/Data/Observation/ObservationUtil.swift @@ -0,0 +1,229 @@ +// +// ObservationUtil.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Blocked by TraceEvent +// ID: 7DF024579E4FC31D4E92A33BBA0366D6 (SwiftUI?) + +import Foundation +package import OpenAttributeGraphShims +@_spi(OpenSwiftUI) +package import OpenObservation + +// MARK: - ObservationEntry + +private struct ObservationEntry { + let context: AnyObject + var properties: Set + + func union(_ entry: ObservationEntry) -> ObservationEntry { + ObservationEntry(context: context, properties: properties.union(entry.properties)) + } +} + +extension ObservationTracking._AccessList { + mutating func merge(_ other: ObservationTracking._AccessList) { + withUnsafeMutablePointer(to: &self) { ptr1 in + withUnsafePointer(to: other) { ptr2 in + let selfPtr = UnsafeMutableRawPointer(mutating: ptr1) + .assumingMemoryBound(to: [ObjectIdentifier: ObservationEntry].self) + let otherPtr = UnsafeRawPointer(ptr2) + .assumingMemoryBound(to: [ObjectIdentifier: ObservationEntry].self) + selfPtr.pointee.merge(otherPtr.pointee) { $0.union($1) } + } + } + } +} + +// MARK: - ObservationGraphMutation + +private struct ObservationGraphMutation: GraphMutation { + var invalidatingMutation: InvalidatingGraphMutation + var observationTracking: [ObservationTracking] + var subgraphObservers: [(Subgraph, Int)] + + func apply() { + for (subgraph, observerID) in subgraphObservers { + subgraph.removeObserver(observerID) + } + ObservationRegistrar.latestTriggers.removeAll(keepingCapacity: true) + + for tracking in observationTracking { + if let changedKeyPath = tracking.changed { + ObservationRegistrar.latestTriggers.append(changedKeyPath) + } + tracking.cancel() + } + invalidatingMutation.apply() + ObservationRegistrar.invalidations.value.removeValue(forKey: invalidatingMutation.attribute) + } + + mutating func combine(with other: T) -> Bool where T: GraphMutation { + guard invalidatingMutation.combine(with: other) else { + return false + } + if let otherObservation = other as? ObservationGraphMutation { + observationTracking.append(contentsOf: otherObservation.observationTracking) + subgraphObservers.append(contentsOf: otherObservation.subgraphObservers) + } + return true + } + + func cancel() { + for tracking in observationTracking { + tracking.cancel() + } + for (subgraph, observerID) in subgraphObservers { + subgraph.removeObserver(observerID) + } + } +} + +// MARK: - ObservationRegistrar + Extension + +extension ObservationRegistrar { + package static var latestTriggers: [AnyKeyPath] = [] + + package static var latestAccessLists: [ObservationTracking._AccessList] = [] + + fileprivate static var invalidations: ThreadSpecific<[AnyWeakAttribute: (mutation: ObservationGraphMutation, accessList: ObservationTracking._AccessList)]> = .init([:]) +} + +// MARK: - Observation Utilities + +@inline(__always) +package func _withObservation( + do work: () throws -> T +) rethrows -> (value: T, accessList: ObservationTracking._AccessList?) { + var accessList: ObservationTracking._AccessList? + let result = try withUnsafeMutablePointer(to: &accessList) { ptr in + let previous = _ThreadLocal.value + _ThreadLocal.value = UnsafeMutableRawPointer(ptr) + defer { _ThreadLocal.value = previous } + return try work() + } + if let accessList { + ObservationRegistrar.latestAccessLists.append(accessList) + } + return (result, accessList) +} + +@inline(__always) +package func _withObservation( + attribute: Attribute, + do work: () throws -> T +) rethrows -> T { + let previousAccessLists = ObservationRegistrar.latestAccessLists + ObservationRegistrar.latestAccessLists = [] + defer { ObservationRegistrar.latestAccessLists = previousAccessLists } + + let (result, _) = try _withObservation(do: work) + for accessList in ObservationRegistrar.latestAccessLists { + installObservationSlow(accessList: accessList, attribute: attribute) + } + return result +} + +@inline(__always) +package func _installObservation( + accessLists: [ObservationTracking._AccessList], + attribute: Attribute +) { + guard !accessLists.isEmpty else { return } + for accessList in accessLists { + installObservationSlow(accessList: accessList, attribute: attribute) + } +} + +private func installObservationSlow( + accessList: ObservationTracking._AccessList, + attribute: Attribute +) { + guard let subgraph = attribute.identifier.subgraph2 else { + return + } + let weakViewGraph = WeakUncheckedSendable(ViewGraph.current) + let weakAttribute = AnyWeakAttribute(attribute.identifier) + + var newAccessList = accessList + let removedValue = ObservationRegistrar.invalidations.value.removeValue(forKey: weakAttribute) + if let removedValue { + newAccessList.merge(removedValue.accessList) + removedValue.mutation.cancel() + } + + let tracking = ObservationTracking(newAccessList) + let observerID = subgraph.addObserver { + let removedValue = ObservationRegistrar.invalidations.value.removeValue(forKey: weakAttribute) + if let removedValue { + removedValue.mutation.cancel() + } + } + let mutation = ObservationGraphMutation( + invalidatingMutation: InvalidatingGraphMutation(attribute: weakAttribute), + observationTracking: [tracking], + subgraphObservers: [(subgraph, observerID)] + ) + ObservationRegistrar.invalidations.value[weakAttribute] = (mutation: mutation, accessList: newAccessList) + ObservationTracking._installTracking( + tracking, + willSet: { tracking in + guard subgraph.isValid else { return } + Update.ensure { + guard let attribute = weakAttribute.attribute, + let viewGraph = weakViewGraph.value else { + mutation.cancel() + return + } + // TODO: transaction result + let _ = viewGraph.asyncTransaction( + Transaction.current, + mutation: mutation, + style: Thread.isMainThread ? .immediate : .deferred, + ) + // TODO: AGGraphAddTraceEvent + } + } + ) +} + +// MARK: - Rule + Observation + +extension Rule { + @inline(__always) + package func withObservation(do work: () throws -> T) rethrows -> T { + try _withObservation(attribute: attribute, do: work) + } + + package var observationInstaller: (ObservationTracking._AccessList) -> Void { + { [attribute] accessList in + guard attribute.subgraph.isValid else { + return + } + attribute.subgraph.apply { + installObservationSlow(accessList: accessList, attribute: attribute) + } + } + } +} + +// MARK: - StatefulRule + Observation + +extension StatefulRule { + @inline(__always) + package func withObservation(do work: () throws -> T) rethrows -> T { + try _withObservation(attribute: attribute, do: work) + } + + package var observationInstaller: (ObservationTracking._AccessList) -> Void { + { [attribute] accessList in + guard attribute.subgraph.isValid else { + return + } + attribute.subgraph.apply { + installObservationSlow(accessList: accessList, attribute: attribute) + } + } + } +} diff --git a/Sources/OpenSwiftUICore/Data/State/State.swift b/Sources/OpenSwiftUICore/Data/State/State.swift index 5b5a0e4aa..9a7eae7b8 100644 --- a/Sources/OpenSwiftUICore/Data/State/State.swift +++ b/Sources/OpenSwiftUICore/Data/State/State.swift @@ -266,7 +266,7 @@ private struct StatePropertyBox: DynamicPropertyBox { signal: signal ) } - let signalChanged = signal.changedValue()?.changed ?? false + let signalChanged = signal.changedValue(options: [])?.changed ?? false property._value = location!.updateValue property._location = location! return (signalChanged ? location!.wasRead : false) || locationChanged diff --git a/Sources/OpenSwiftUICore/OpenGraph/AnyAttributeFix.swift b/Sources/OpenSwiftUICore/Util/AnyAttributeFix.swift similarity index 97% rename from Sources/OpenSwiftUICore/OpenGraph/AnyAttributeFix.swift rename to Sources/OpenSwiftUICore/Util/AnyAttributeFix.swift index 7fe20215b..337f1e24e 100644 --- a/Sources/OpenSwiftUICore/OpenGraph/AnyAttributeFix.swift +++ b/Sources/OpenSwiftUICore/Util/AnyAttributeFix.swift @@ -76,6 +76,14 @@ extension AnyAttribute { package func invalidateValue() { preconditionFailure("#39") } + + package var subgraph: Subgraph { + preconditionFailure("#39") + } + + package var subgraph2: Subgraph? { + preconditionFailure("#39") + } } extension AnyAttribute { diff --git a/Sources/OpenSwiftUICore/OpenGraph/OpenGraphAdditions.swift b/Sources/OpenSwiftUICore/Util/AttributeGraphAdditions.swift similarity index 99% rename from Sources/OpenSwiftUICore/OpenGraph/OpenGraphAdditions.swift rename to Sources/OpenSwiftUICore/Util/AttributeGraphAdditions.swift index aff0ba357..2d3677c4f 100644 --- a/Sources/OpenSwiftUICore/OpenGraph/OpenGraphAdditions.swift +++ b/Sources/OpenSwiftUICore/Util/AttributeGraphAdditions.swift @@ -1,5 +1,5 @@ // -// OpenAttributeGraphAdditions.swift +// AttributeGraphAdditions.swift // OpenSwiftUICore // // Status: WIP diff --git a/Tests/OpenSwiftUICoreTests/Data/Observation/ObservationUtilTests.swift b/Tests/OpenSwiftUICoreTests/Data/Observation/ObservationUtilTests.swift new file mode 100644 index 000000000..44f4b69d5 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Data/Observation/ObservationUtilTests.swift @@ -0,0 +1,106 @@ +// +// ObservationUtilTests.swift +// OpenSwiftUICoreTests +// + +import Testing +import OpenAttributeGraphShims +@_spi(OpenSwiftUI) +import OpenObservation +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +@Suite("ObservationUtil Tests") +struct ObservationUtilTests { + + @Observable + final class TestModel { + var value: Int = 0 + var text: String = "" + } + + @Test("_withObservation tracks property access") + func withObservationTracking() { + let model = TestModel() + + // Test that we can track observation access + let (result, accessList) = _withObservation { + // Access the model's properties + let _ = model.value + let _ = model.text + return model.value + 10 + } + + // Verify the result + #expect(result == 10) + + // Verify that an access list was created + #expect(accessList != nil) + } + + @MainActor + @Test( + "_withObservation with attribute installation", + .disabled("retain a invalid ptr and cause crash. Investigate it later.") // TODO + ) + func withObservationAttribute() { + let model = TestModel() + let viewGraph = ViewGraph(rootViewType: EmptyView.self) + viewGraph.rootSubgraph.apply { + let attribute = Attribute(value: 0) + + // Use _withObservation with an attribute + let result = _withObservation(attribute: attribute) { + // Access the model's properties + let sum = model.value + 5 + return sum + } + + #expect(result == 5) + } + } + + @Test("Nested observation contexts") + func nestedObservationContexts() { + let model1 = TestModel() + let model2 = TestModel() + + model1.value = 10 + model2.value = 20 + + let (outerResult, outerAccessList) = _withObservation { + let val1 = model1.value + + // Nested observation context + let (innerResult, innerAccessList) = _withObservation { + let val2 = model2.value + return val2 * 2 + } + + #expect(innerResult == 40) + #expect(innerAccessList != nil) + + return val1 + innerResult + } + + #expect(outerResult == 50) + #expect(outerAccessList != nil) + } + + @Test("ObservationRegistrar latestAccessLists tracking") + func latestAccessListsTracking() { + let model = TestModel() + + // Clear any previous access lists + ObservationRegistrar.latestAccessLists = [] + + let (_, accessList) = _withObservation { + let _ = model.value + return "test" + } + + // Verify that an access list was created and recorded + #expect(accessList != nil) + #expect(!ObservationRegistrar.latestAccessLists.isEmpty) + } +}