From 0a80c9c64e3c9be797534d9de87aaf5dbdd45dff Mon Sep 17 00:00:00 2001 From: Julien Sagot Date: Sun, 19 Jul 2026 12:29:44 +0200 Subject: [PATCH] Apply mutations before enqueueing their main-queue change notifications --- Sources/Sharing/Internal/Reference.swift | 6 +- Sources/Sharing/Shared.swift | 3 +- Sources/Sharing/SharedReader.swift | 3 +- .../NotificationOrderingTests.swift | 68 +++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 Tests/SharingTests/NotificationOrderingTests.swift diff --git a/Sources/Sharing/Internal/Reference.swift b/Sources/Sharing/Internal/Reference.swift index 2fb56d1..7c9ea54 100644 --- a/Sources/Sharing/Internal/Reference.swift +++ b/Sources/Sharing/Internal/Reference.swift @@ -155,10 +155,11 @@ final class _BoxReference: MutableReference, Observable, Perceptible, @un if Thread.isMainThread { return try _$perceptionRegistrar.withMutation(of: self, keyPath: keyPath, mutation) } else { + let result = try mutation() DispatchQueue.main.async { self._$perceptionRegistrar.withMutation(of: self, keyPath: keyPath) {} } - return try mutation() + return result } #endif } @@ -339,10 +340,11 @@ final class _PersistentReference: if Thread.isMainThread { return try _$perceptionRegistrar.withMutation(of: self, keyPath: keyPath, mutation) } else { + let result = try mutation() DispatchQueue.main.async { self._$perceptionRegistrar.withMutation(of: self, keyPath: keyPath) {} } - return try mutation() + return result } #endif } diff --git a/Sources/Sharing/Shared.swift b/Sources/Sharing/Shared.swift index e5fcdfb..f8c7d4c 100644 --- a/Sources/Sharing/Shared.swift +++ b/Sources/Sharing/Shared.swift @@ -221,8 +221,9 @@ public struct Shared { return self } nonmutating set { - reference.touch() + let oldReference = reference reference = newValue.reference + oldReference.touch() } } diff --git a/Sources/Sharing/SharedReader.swift b/Sources/Sharing/SharedReader.swift index 1446474..af94652 100644 --- a/Sources/Sharing/SharedReader.swift +++ b/Sources/Sharing/SharedReader.swift @@ -150,8 +150,9 @@ public struct SharedReader { return self } nonmutating set { - reference.touch() + let oldReference = reference reference = newValue.reference + oldReference.touch() } } diff --git a/Tests/SharingTests/NotificationOrderingTests.swift b/Tests/SharingTests/NotificationOrderingTests.swift new file mode 100644 index 0000000..55931af --- /dev/null +++ b/Tests/SharingTests/NotificationOrderingTests.swift @@ -0,0 +1,68 @@ +import Dispatch +import PerceptionCore +import Sharing +import Testing + +#if canImport(Combine) + import Combine + + @Suite struct NotificationOrderingTests { + @Test(.timeLimit(.minutes(1))) + func changeNotificationIsEnqueuedAfterBackgroundStore() async throws { + @SharedReader(wrappedValue: 0, BackgroundLoadKey(value: 1)) var value: Int + let reader = $value + try await poll(until: { reader.wrappedValue == 1 }) + // NB: Drain the initial load's pending change notification before observing. + await MainActor.run {} + + let events = Mutex<[String]>([]) + let recordStore: @Sendable () -> Void = { events.withLock { $0.append("store") } } + let cancellable = reader.publisher + .dropFirst() + .sink { @Sendable _ in + DispatchQueue.main.async(execute: recordStore) + } + defer { _ = cancellable } + + withPerceptionTracking { + _ = reader.wrappedValue + } onChange: { + events.withLock { $0.append("change") } + } + + try await Task.detached { try await reader.load() }.value + try await poll(until: { events.withLock(\.count) >= 2 }) + + #expect(events.withLock(\.self) == ["store", "change"]) + } + } + + private struct BackgroundLoadKey: SharedReaderKey { + let value: Int + var id: Int { value } + + func load(context: LoadContext, continuation: LoadContinuation) { + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: value) + } + } + + func subscribe( + context: LoadContext, + subscriber: SharedSubscriber + ) -> SharedSubscription { + SharedSubscription {} + } + } + + private func poll( + until condition: @escaping @Sendable () -> Bool, + timeout: Duration = .seconds(3) + ) async throws { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if condition() { return } + try await Task.sleep(for: .milliseconds(10)) + } + } +#endif