From cd95666552f26c45055c905fbfdac204b4867bbb Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Tue, 22 Sep 2026 15:27:36 +0300 Subject: [PATCH 1/2] fix: defer observed satisfies publications --- .../Wrappers/ObservedSatisfies.swift | 39 +++++++++++++------ .../ObservedSatisfiesTests.swift | 2 + 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/Sources/SpecificationKit/Wrappers/ObservedSatisfies.swift b/Sources/SpecificationKit/Wrappers/ObservedSatisfies.swift index 3ed794e..c239da4 100644 --- a/Sources/SpecificationKit/Wrappers/ObservedSatisfies.swift +++ b/Sources/SpecificationKit/Wrappers/ObservedSatisfies.swift @@ -173,18 +173,25 @@ final class Observer: ObservableObject { @Published var value: Bool = false var cancellable: AnyCancellable? + + init(value: Bool) { + self.value = value + } + func wire(updates: AnyPublisher?, evaluate: @escaping () -> Bool) { - // compute immediately - let v = evaluate() - if v != value { value = v } guard cancellable == nil, let updates else { return } cancellable = updates .receive(on: DispatchQueue.main) .sink { [weak self] in - guard let self else { return } - let v2 = evaluate() - if v2 != self.value { self.value = v2 } + // Defer publication until the current SwiftUI update has + // completed. Providers can emit while a view is being + // reconciled, and publishing from that callback is undefined. + DispatchQueue.main.async { + guard let self else { return } + let v = evaluate() + if v != self.value { self.value = v } + } } } } @@ -216,15 +223,19 @@ provider: Provider, using specification: Spec ) where Provider.Context == Context, Spec.T == Context { - self.contextFactory = provider.currentContext - self.specification = AnySpecification(specification) + let contextFactory = provider.currentContext + let specification = AnySpecification(specification) + self.contextFactory = contextFactory + self.specification = specification #if canImport(Combine) if let p = provider as? ContextUpdatesProviding { self.updates = p.contextUpdates } else { self.updates = nil } - self._observer = ObservedObject(wrappedValue: Observer()) + self._observer = ObservedObject( + wrappedValue: Observer(value: specification.isSatisfiedBy(contextFactory())) + ) #endif } @@ -232,15 +243,19 @@ provider: Provider, predicate: @escaping (Context) -> Bool ) where Provider.Context == Context { - self.contextFactory = provider.currentContext - self.specification = AnySpecification { ctx in predicate(ctx) } + let contextFactory = provider.currentContext + let specification = AnySpecification { ctx in predicate(ctx) } + self.contextFactory = contextFactory + self.specification = specification #if canImport(Combine) if let p = provider as? ContextUpdatesProviding { self.updates = p.contextUpdates } else { self.updates = nil } - self._observer = ObservedObject(wrappedValue: Observer()) + self._observer = ObservedObject( + wrappedValue: Observer(value: specification.isSatisfiedBy(contextFactory())) + ) #endif } diff --git a/Tests/SpecificationKitTests/ObservedSatisfiesTests.swift b/Tests/SpecificationKitTests/ObservedSatisfiesTests.swift index 043e89f..7371a9b 100644 --- a/Tests/SpecificationKitTests/ObservedSatisfiesTests.swift +++ b/Tests/SpecificationKitTests/ObservedSatisfiesTests.swift @@ -112,6 +112,7 @@ import XCTest // When var observedSatisfiesTrue = ObservedSatisfies(provider: testProvider, using: spec) + XCTAssertTrue(observedSatisfiesTrue.wrappedValue) observedSatisfiesTrue.update() // Update provider context @@ -134,6 +135,7 @@ import XCTest // When var observedSatisfiesTrue = ObservedSatisfies( provider: testProvider, predicate: predicate) + XCTAssertTrue(observedSatisfiesTrue.wrappedValue) observedSatisfiesTrue.update() // Update provider context From c159f6a9abc834fdbb38695c2ecd8841a15784f7 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Tue, 22 Sep 2026 16:13:49 +0300 Subject: [PATCH 2/2] test: cover pre-wiring context changes --- .../Wrappers/ObservedSatisfies.swift | 10 ++++ .../ObservedSatisfiesTests.swift | 58 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/Sources/SpecificationKit/Wrappers/ObservedSatisfies.swift b/Sources/SpecificationKit/Wrappers/ObservedSatisfies.swift index c239da4..48a9aed 100644 --- a/Sources/SpecificationKit/Wrappers/ObservedSatisfies.swift +++ b/Sources/SpecificationKit/Wrappers/ObservedSatisfies.swift @@ -193,6 +193,16 @@ if v != self.value { self.value = v } } } + + // The provider may have changed between initialization and the + // first DynamicProperty.update(). Re-evaluate after wiring so an + // event emitted in that gap is not lost, without publishing from + // inside SwiftUI's update pass. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let v = evaluate() + if v != self.value { self.value = v } + } } } diff --git a/Tests/SpecificationKitTests/ObservedSatisfiesTests.swift b/Tests/SpecificationKitTests/ObservedSatisfiesTests.swift index 7371a9b..535b1e9 100644 --- a/Tests/SpecificationKitTests/ObservedSatisfiesTests.swift +++ b/Tests/SpecificationKitTests/ObservedSatisfiesTests.swift @@ -40,6 +40,36 @@ import XCTest } } + #if canImport(Combine) + final class UpdatingContextProvider: ContextProviding, ContextUpdatesProviding { + typealias Context = EvaluationContext + + private var context: EvaluationContext + private let updatesSubject = PassthroughSubject() + + init(context: EvaluationContext) { + self.context = context + } + + func currentContext() -> EvaluationContext { + context + } + + var contextUpdates: AnyPublisher { + updatesSubject.eraseToAnyPublisher() + } + + var contextStream: AsyncStream { + AsyncStream { _ in } + } + + func updateContext(_ context: EvaluationContext) { + self.context = context + updatesSubject.send() + } + } + #endif + // MARK: - Basic Initialization Tests func testObservedSatisfies_InitWithDefaultProvider() { @@ -149,6 +179,34 @@ import XCTest XCTAssertNotNil(observedSatisfiesFalse) } + #if canImport(Combine) + func testObservedSatisfies_ReevaluatesWhenContextChangesBeforeFirstUpdate() { + // Given + let provider = UpdatingContextProvider( + context: EvaluationContext(flags: ["feature_enabled": true]) + ) + var observedSatisfies = ObservedSatisfies( + provider: provider, + predicate: { $0.flag(for: "feature_enabled") } + ) + XCTAssertTrue(observedSatisfies.wrappedValue) + + // The provider emits before DynamicProperty.update() wires the observer. + provider.updateContext(EvaluationContext(flags: ["feature_enabled": false])) + + // When + observedSatisfies.update() + + // Then - The deferred first evaluation observes the latest context. + let expectation = expectation(description: "deferred evaluation") + DispatchQueue.main.async { + XCTAssertFalse(observedSatisfies.wrappedValue) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + #endif + // MARK: - Edge Cases func testObservedSatisfies_WithComplexSpecification() {