-
-
Save janodev/d0f8e6432afd15ae20d886c3a0e51519 to your computer and use it in GitHub Desktop.
Broken backport of Observations.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Foundation | |
| import Observation | |
| /** | |
| Backport of iOS 18's Observations type for iOS 17+/macOS 14+ | |
| Creates an AsyncSequence that emits values whenever tracked properties | |
| of @Observable objects change. | |
| Example usage: | |
| ```swift | |
| @Observable | |
| class Player { | |
| var score = 0 | |
| var lives = 3 | |
| } | |
| let player = Player() | |
| let scoreStream = Observations26 { player.score } | |
| Task { | |
| for await newScore in scoreStream { | |
| print("Score changed to: \(newScore)") | |
| } | |
| } | |
| ``` | |
| ## Implementation Limitations | |
| This backport is not viable because we lack access to Swift's internal runtime APIs. | |
| ### What Apple's Implementation Has (that we don’t): | |
| 1. **Internal Runtime Hooks**: Access to `swift_task_addCancellationHandler` and `swift_task_removeCancellationHandler` | |
| - Enables proper cleanup of continuations without race conditions when tasks are cancelled | |
| 2. **Isolated Task Cancellation**: `withIsolatedTaskCancellationHandler` with `@isolated(any)` support | |
| - Allows tracking changes on any actor isolation, not just MainActor, improving performance and flexibility | |
| 3. **ManagedCriticalState**: Thread-safe state management primitive not available in public API | |
| - Provides lock-free synchronization for the dirty flag, preventing missed changes during re-registration | |
| 4. **Direct Observation Integration**: Lower-level access to observation mechanisms | |
| - Can synchronously re-register observations within onChange, capturing all rapid sequential changes | |
| ### The Core Limitation: | |
| `withObservationTracking` only fires its `onChange` callback ONCE, then requires re-registration. | |
| When multiple synchronous changes occur (e.g., `score = 1; score = 2; score = 3`), the callback | |
| fires after ALL changes complete. By the time we asynchronously re-register, no more changes occur, | |
| so we miss the final value. | |
| ### Known Issues: | |
| - **Rapid synchronous changes**: May only capture initial value, missing final value | |
| - **Change coalescing**: Multiple quick changes may be coalesced (this is actually desired for UI) | |
| - **MainActor requirement**: Cannot dynamically schedule on arbitrary actors like Apple's version | |
| ### When This Works Well: | |
| - Normal UI observation with user interactions | |
| - Async property updates (network calls, timers) | |
| - Most real-world SwiftUI/UIKit scenarios | |
| ### When to Use Alternatives: | |
| - If you need every single change: Use Combine's @Published | |
| - If you can require iOS 18+: Use native Observations | |
| - If you need synchronous change capture: Consider custom property wrappers | |
| Note: The coalescing behavior is intentional in Apple's design to optimize UI performance. | |
| This backport provides the best possible implementation using only public APIs, but not | |
| getting the final value during quick changes is deal-breaking. | |
| See Apple’s implementation: | |
| - https://github.com/swiftlang/swift/tree/main/stdlib/public/Observation/Sources/Observation | |
| */ | |
| @available(iOS 17.0, macOS 14.0, *) | |
| public struct Observations26<T: Sendable>: AsyncSequence, Sendable { | |
| public typealias Element = T | |
| private let tracking: @Sendable () -> T | |
| /// Creates an async sequence that tracks changes to the specified properties | |
| /// - Parameter tracking: A closure that accesses the properties to track | |
| public init(observing tracking: @escaping @Sendable () -> T) { | |
| self.tracking = tracking | |
| } | |
| /// Convenience initializer using autoclosure for simple property access | |
| /// - Parameter value: An autoclosure that accesses the properties to track | |
| public init(_ value: @escaping @Sendable @autoclosure () -> T) { | |
| self.tracking = value | |
| } | |
| public func makeAsyncIterator() -> AsyncIterator { | |
| AsyncIterator(tracking: tracking) | |
| } | |
| public struct AsyncIterator: AsyncIteratorProtocol { | |
| private var iterator: AsyncStream<T>.Iterator | |
| init(tracking: @escaping @Sendable () -> T) { | |
| // Create stream with buffering | |
| let stream = AsyncStream<T>(bufferingPolicy: .bufferingOldest(100)) { continuation in | |
| // Everything must happen synchronously on MainActor | |
| Task { @MainActor in | |
| // Get initial value | |
| let initialValue = tracking() | |
| continuation.yield(initialValue) | |
| // Start observation loop | |
| Self.observeRecursively( | |
| tracking: tracking, | |
| continuation: continuation, | |
| previousValue: initialValue | |
| ) | |
| } | |
| } | |
| self.iterator = stream.makeAsyncIterator() | |
| } | |
| public mutating func next() async -> T? { | |
| await iterator.next() | |
| } | |
| @MainActor | |
| private static func observeRecursively( | |
| tracking: @escaping @Sendable () -> T, | |
| continuation: AsyncStream<T>.Continuation, | |
| previousValue: T | |
| ) { | |
| guard !Task.isCancelled else { | |
| continuation.finish() | |
| return | |
| } | |
| // Set up observation for the next change | |
| withObservationTracking { | |
| _ = tracking() | |
| } onChange: { | |
| // onChange is called when a change occurs | |
| // Schedule the value capture on MainActor | |
| Task { @MainActor in | |
| guard !Task.isCancelled else { | |
| continuation.finish() | |
| return | |
| } | |
| // Get the new value | |
| let newValue = tracking() | |
| continuation.yield(newValue) | |
| // Re-register for the next change immediately | |
| observeRecursively( | |
| tracking: tracking, | |
| continuation: continuation, | |
| previousValue: newValue | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // MARK: - Convenience Initializers | |
| @available(iOS 17.0, macOS 14.0, *) | |
| public extension Observations26 { | |
| /// Creates an async sequence tracking multiple properties | |
| /// - Parameter tracking: A closure that accesses multiple properties to track | |
| init<each U: Sendable>( | |
| _ tracking: @escaping @Sendable @autoclosure () -> (repeat each U) | |
| ) where T == (repeat each U) { | |
| self.tracking = tracking | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Foundation | |
| import Observation | |
| import Testing | |
| @testable import Semly | |
| @Observable | |
| final class TestPlayer: @unchecked Sendable { | |
| var score = 0 | |
| var lives = 3 | |
| var name = "Player 1" | |
| } | |
| @Observable | |
| final class TestGameState: @unchecked Sendable { | |
| var level = 1 | |
| var isPaused = false | |
| } | |
| @Observable | |
| final class TestPerson: @unchecked Sendable { | |
| var firstName = "John" | |
| var lastName = "Doe" | |
| var fullName: String { "\(firstName) \(lastName)" } | |
| } | |
| @Observable | |
| final class TestParent: @unchecked Sendable { | |
| var child = TestChild() | |
| } | |
| @Observable | |
| final class TestChild: @unchecked Sendable { | |
| var value = 0 | |
| } | |
| @Observable | |
| final class TestContainer: @unchecked Sendable { | |
| var optionalPlayer: TestPlayer? | |
| } | |
| actor ValueCollector<T: Sendable> { | |
| private var values: [T] = [] | |
| func append(_ value: T) { | |
| values.append(value) | |
| } | |
| func getValues() -> [T] { | |
| values | |
| } | |
| func count() -> Int { | |
| values.count | |
| } | |
| } | |
| @Suite("Observations26 Tests") | |
| struct Observations26Tests { | |
| @Test("Emits initial value immediately") | |
| func emitsInitialValue() async throws { | |
| let player = TestPlayer() | |
| player.score = 42 | |
| let observations = Observations26(observing: { player.score }) | |
| var iterator = observations.makeAsyncIterator() | |
| let firstValue = await iterator.next() | |
| #expect(firstValue == 42) | |
| } | |
| @Test("Tracks single property changes") | |
| func tracksSinglePropertyChanges() async throws { | |
| let player = TestPlayer() | |
| let observations = Observations26(observing: { player.score }) | |
| let collector = ValueCollector<Int>() | |
| let collectionTask = Task { | |
| for await score in observations { | |
| await collector.append(score) | |
| let count = await collector.count() | |
| if count >= 3 { | |
| break | |
| } | |
| } | |
| } | |
| defer { collectionTask.cancel() } | |
| // Wait longer under system load for initial value | |
| try await Task.sleep(nanoseconds: 200_000_000) | |
| var values = await collector.getValues() | |
| #expect(values.count >= 1) | |
| if !values.isEmpty { | |
| #expect(values[0] == 0) | |
| } | |
| await MainActor.run { player.score = 10 } | |
| try await Task.sleep(nanoseconds: 50_000_000) | |
| await MainActor.run { player.score = 25 } | |
| try await Task.sleep(nanoseconds: 50_000_000) | |
| values = await collector.getValues() | |
| #expect(values == [0, 10, 25]) | |
| } | |
| @Test("Ignores changes to untracked properties") | |
| func ignoresUntrackedProperties() async throws { | |
| let player = TestPlayer() | |
| let observations = Observations26(observing: { player.score }) | |
| let collector = ValueCollector<Int>() | |
| let collectionTask = Task { | |
| for await score in observations { | |
| await collector.append(score) | |
| let count = await collector.count() | |
| if count >= 3 { | |
| break | |
| } | |
| } | |
| } | |
| defer { collectionTask.cancel() } | |
| // Wait longer under system load for initial value | |
| try await Task.sleep(nanoseconds: 200_000_000) | |
| var values = await collector.getValues() | |
| #expect(values.count >= 1) | |
| if !values.isEmpty { | |
| #expect(values[0] == 0) | |
| } | |
| await MainActor.run { | |
| player.lives = 5 | |
| player.name = "Updated Name" | |
| } | |
| try await Task.sleep(nanoseconds: 50_000_000) | |
| values = await collector.getValues() | |
| #expect(values == [0]) | |
| await MainActor.run { player.score = 100 } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| values = await collector.getValues() | |
| #expect(values == [0, 100]) | |
| } | |
| @Test("Tracks multiple properties using tuple") | |
| func tracksMultipleProperties() async throws { | |
| let player = TestPlayer() | |
| let observations = Observations26(observing: { (player.score, player.lives) }) | |
| let collector = ValueCollector<(Int, Int)>() | |
| let collectionTask = Task { | |
| for await value in observations { | |
| await collector.append(value) | |
| let count = await collector.count() | |
| if count >= 3 { | |
| break | |
| } | |
| } | |
| } | |
| defer { collectionTask.cancel() } | |
| // Wait longer for initial value to be collected | |
| try await Task.sleep(nanoseconds: 200_000_000) | |
| var values = await collector.getValues() | |
| #expect(values.count >= 1) | |
| if !values.isEmpty { | |
| #expect(values[0].0 == 0) | |
| #expect(values[0].1 == 3) | |
| } | |
| await MainActor.run { player.score = 50 } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| await MainActor.run { player.lives = 2 } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| values = await collector.getValues() | |
| #expect(values.count >= 3) | |
| // Just verify we see initial and some changes (coalescing is OK) | |
| #expect(values.first?.0 == 0) | |
| #expect(values.first?.1 == 3) | |
| // Final state should be captured eventually | |
| let hasFinalState = values.contains { $0.0 == 50 && $0.1 == 2 } | |
| #expect(hasFinalState || (values.last?.0 == 50 && values.last?.1 == 2)) | |
| } | |
| @Test("Handles computed properties") | |
| func handlesComputedProperties() async throws { | |
| let person = TestPerson() | |
| let observations = Observations26(observing: { person.fullName }) | |
| let collector = ValueCollector<String>() | |
| let collectionTask = Task { | |
| for await fullName in observations { | |
| await collector.append(fullName) | |
| let count = await collector.count() | |
| if count >= 3 { | |
| break | |
| } | |
| } | |
| } | |
| defer { collectionTask.cancel() } | |
| // Wait longer under system load for initial value | |
| try await Task.sleep(nanoseconds: 200_000_000) | |
| var values = await collector.getValues() | |
| #expect(values.count >= 1) | |
| if !values.isEmpty { | |
| #expect(values[0] == "John Doe") | |
| } | |
| await MainActor.run { person.firstName = "Jane" } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| await MainActor.run { person.lastName = "Smith" } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| values = await collector.getValues() | |
| // Coalescing is expected - just verify we see the final state | |
| #expect(values.first == "John Doe") | |
| #expect(values.contains("Jane Smith") || values.last == "Jane Smith") | |
| } | |
| @Test("Cancels observation when task is cancelled") | |
| func cancelsObservationOnTaskCancel() async throws { | |
| let player = TestPlayer() | |
| let observations = Observations26(observing: { player.score }) | |
| let collector = ValueCollector<Int>() | |
| let task = Task { | |
| for await score in observations { | |
| await collector.append(score) | |
| } | |
| } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| await MainActor.run { player.score = 10 } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| task.cancel() | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| await MainActor.run { player.score = 20 } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| let values = await collector.getValues() | |
| #expect(values.count <= 2) | |
| #expect(task.isCancelled) | |
| } | |
| // KNOWN FAILURE: Both observers miss the second change (to 15) | |
| // WHY: Re-registration of observation after first change (to 5) | |
| // doesn't happen fast enough before second change occurs. | |
| // This is the async re-registration timing gap issue. | |
| @Test("Works with multiple concurrent observers") | |
| func worksWithMultipleConcurrentObservers() async throws { | |
| let player = TestPlayer() | |
| let observations1 = Observations26(observing: { player.score }) | |
| let observations2 = Observations26(observing: { player.score }) | |
| let collector1 = ValueCollector<Int>() | |
| let collector2 = ValueCollector<Int>() | |
| let task1 = Task { | |
| for await score in observations1 { | |
| await collector1.append(score) | |
| let count = await collector1.count() | |
| if count >= 3 { | |
| break | |
| } | |
| } | |
| } | |
| let task2 = Task { | |
| for await score in observations2 { | |
| await collector2.append(score) | |
| let count = await collector2.count() | |
| if count >= 3 { | |
| break | |
| } | |
| } | |
| } | |
| defer { | |
| task1.cancel() | |
| task2.cancel() | |
| } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| await MainActor.run { player.score = 5 } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| await MainActor.run { player.score = 15 } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| let values1 = await collector1.getValues() | |
| let values2 = await collector2.getValues() | |
| // Both observers should see changes (coalescing is OK) | |
| #expect(values1.first == 0) | |
| #expect(values1.contains(15) || values1.last == 15) | |
| #expect(values2.first == 0) | |
| #expect(values2.contains(15) || values2.last == 15) | |
| } | |
| // KNOWN FAILURE: Only sees initial value (0), not final value (5) | |
| // WHY: When all changes happen synchronously in MainActor.run block, | |
| // onChange fires ONCE after all changes complete. By the time we | |
| // re-register observation asynchronously, no more changes occur. | |
| @Test("Handles rapid consecutive changes") | |
| func handlesRapidConsecutiveChanges() async throws { | |
| let player = TestPlayer() | |
| let observations = Observations26(observing: { player.score }) | |
| let collector = ValueCollector<Int>() | |
| let task = Task { | |
| for await score in observations { | |
| await collector.append(score) | |
| } | |
| } | |
| defer { task.cancel() } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| await MainActor.run { | |
| player.score = 1 | |
| player.score = 2 | |
| player.score = 3 | |
| player.score = 4 | |
| player.score = 5 | |
| } | |
| try await Task.sleep(nanoseconds: 50_000_000) | |
| let values = await collector.getValues() | |
| // Just verify we got initial and final values - intermediate coalescing is OK | |
| #expect(values.first == 0) | |
| #expect(values.last == 5) | |
| #expect(values.count >= 1) // At least one value was emitted | |
| } | |
| // KNOWN FAILURE: Doesn't see the change from 0 to 42 | |
| // WHY: Nested property observation (parent.child.value) has additional | |
| // complexity in tracking chain. The onChange may not fire correctly | |
| // for deeply nested observable properties. | |
| @Test("Tracks nested observable objects") | |
| func tracksNestedObservableObjects() async throws { | |
| let parent = TestParent() | |
| let observations = Observations26(observing: { parent.child.value }) | |
| let collector = ValueCollector<Int>() | |
| let collectionTask = Task { | |
| for await value in observations { | |
| await collector.append(value) | |
| let count = await collector.count() | |
| if count >= 2 { | |
| break | |
| } | |
| } | |
| } | |
| defer { collectionTask.cancel() } | |
| // Wait longer under system load for initial value | |
| try await Task.sleep(nanoseconds: 200_000_000) | |
| var values = await collector.getValues() | |
| #expect(values.count >= 1) | |
| if !values.isEmpty { | |
| #expect(values[0] == 0) | |
| } | |
| await MainActor.run { parent.child.value = 42 } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| values = await collector.getValues() | |
| // Coalescing is OK - just verify we see the change eventually | |
| #expect(values.first == 0) | |
| #expect(values.contains(42) || values.last == 42) | |
| } | |
| @Test("Handles optional observable properties") | |
| func handlesOptionalObservableProperties() async throws { | |
| let container = TestContainer() | |
| let observations = Observations26(observing: { container.optionalPlayer?.score ?? -1 }) | |
| let collector = ValueCollector<Int>() | |
| let collectionTask = Task { | |
| for await score in observations { | |
| await collector.append(score) | |
| let count = await collector.count() | |
| if count >= 3 { | |
| break | |
| } | |
| } | |
| } | |
| defer { collectionTask.cancel() } | |
| // Wait longer under system load for initial value | |
| try await Task.sleep(nanoseconds: 200_000_000) | |
| var values = await collector.getValues() | |
| #expect(values.count >= 1) | |
| if !values.isEmpty { | |
| #expect(values[0] == -1) | |
| } | |
| await MainActor.run { | |
| container.optionalPlayer = TestPlayer() | |
| } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| await MainActor.run { | |
| container.optionalPlayer?.score = 77 | |
| } | |
| try await Task.sleep(nanoseconds: 10_000_000) | |
| values = await collector.getValues() | |
| // Coalescing is expected - verify we see final state | |
| #expect(values.first == -1) | |
| #expect(values.contains(77) || values.last == 77) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment