From 7c227877a745c1855decf7dc26b360e0788db7f7 Mon Sep 17 00:00:00 2001 From: Arthur Liu Date: Fri, 31 Jul 2026 00:28:40 -0700 Subject: [PATCH 1/2] Add SwiftUI hosting smoke tests --- Documentation/SwiftUIHostingTests.md | 47 ++ README.md | 5 + ...PObservableBridgeSwiftUIHostingTests.swift | 575 ++++++++++++++++++ 3 files changed, 627 insertions(+) create mode 100644 Documentation/SwiftUIHostingTests.md create mode 100644 Tests/KMPObservableBridgeTests/KMPObservableBridgeSwiftUIHostingTests.swift diff --git a/Documentation/SwiftUIHostingTests.md b/Documentation/SwiftUIHostingTests.md new file mode 100644 index 0000000..823e605 --- /dev/null +++ b/Documentation/SwiftUIHostingTests.md @@ -0,0 +1,47 @@ +# SwiftUI hosting smoke tests + +`KMPObservableBridgeSwiftUIHostingTests` mounts two independent projected +fields in real `UIHostingController` or `NSHostingController` view graphs. The +suite records each subtree's body evaluation and keeps the two fields in +separate `View` identities. + +The smoke tests cover: + +- field B invalidation without reevaluating the field A-only subtree on iOS + 17+ and macOS 14+; +- global invalidation of both projected-field subtrees; +- the production `ObservableObject` fallback forced on a current runtime; +- the real runtime fallback when the same test bundle runs on iOS 15 or iOS + 16; and +- one shared observation setup across the two identities plus deterministic + cancellation after the hosted content is removed. + +The tests use positive render and cancellation signals. Timeouts are failure +bounds only; the suite does not use inverted expectations or sleeps to infer +that an unrelated body was not evaluated. + +## Toolchain and destinations + +The package supports Xcode 15 or newer because its baseline manifest requires +Swift 5.9. The hosting suite is currently verified with Xcode 26.5. + +Run the macOS host through the package's required validation command: + +```sh +swift test \ + --filter KMPObservableBridgeSwiftUIHostingTests \ + -Xswiftc -strict-concurrency=complete \ + -Xswiftc -warnings-as-errors +``` + +For an iOS simulator, open `Package.swift` in the supported Xcode version, +select the `KMPObservableBridge-Package` scheme, choose an iOS simulator, and +run `KMPObservableBridgeSwiftUIHostingTests`. + +- Use an iOS 17 or newer destination for the field-level Observation path. +- Use an iOS 15 or iOS 16 destination for the real runtime fallback contract. + +The forced-fallback test runs on current macOS and iOS runtimes, so the +fallback rendering contract remains covered even when an iOS 15/16 runtime is +not installed. The real-runtime fallback test reports an explicit skip on +Observation-capable destinations. diff --git a/README.md b/README.md index c9197c6..b4477d4 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,11 @@ swift test \ ./Scripts/check-package-manifests.sh ``` +The Apple-platform +[SwiftUI hosting smoke tests](Documentation/SwiftUIHostingTests.md) document +their Xcode requirement, simulator destinations, runtime fallback coverage, +render-count probes, and teardown contract. + Build the real integration fixture without forcing the macro target onto the iOS SDK: diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeSwiftUIHostingTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeSwiftUIHostingTests.swift new file mode 100644 index 0000000..b7122c2 --- /dev/null +++ b/Tests/KMPObservableBridgeTests/KMPObservableBridgeSwiftUIHostingTests.swift @@ -0,0 +1,575 @@ +#if os(iOS) || os(macOS) +import SwiftUI +import XCTest +@testable import KMPObservableBridge + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +@MainActor +final class KMPObservableBridgeSwiftUIHostingTests: XCTestCase { + func testObservationKeepsUnrelatedHostedSubtreeStable() async throws { + let model = HostingModel() + let probe = HostingRenderProbe() + let firstMounted = expectation(description: "First field mounted") + let secondMounted = expectation(description: "Second field mounted") + probe.onNextRender(of: .first) { + firstMounted.fulfill() + } + probe.onNextRender(of: .second) { + secondMounted.fulfill() + } + + let harness = AppleHostingHarness( + rootView: HostingRoot( + model: model, + probe: probe, + mode: .runtime + ) + ) + await fulfillment( + of: [firstMounted, secondMounted], + timeout: 2 + ) + + guard supportsFieldLevelObservation else { + harness.removeContent() + throw XCTSkip( + "Field isolation requires iOS 17 or macOS 14." + ) + } + + XCTAssertEqual(model.first.startedCount, 1) + XCTAssertEqual(model.second.startedCount, 1) + XCTAssertEqual(model.global.startedCount, 1) + let firstBaseline = probe.renderCount(for: .first) + let secondBaseline = probe.renderCount(for: .second) + let secondRendered = expectation( + description: "Second field rerendered" + ) + probe.onNextRender(of: .second) { + secondRendered.fulfill() + } + + model.second.emit(1) + + await fulfillment(of: [secondRendered], timeout: 2) + XCTAssertEqual( + probe.renderCount(for: .first), + firstBaseline, + "A field B emission must not reevaluate the A-only subtree." + ) + XCTAssertGreaterThan( + probe.renderCount(for: .second), + secondBaseline + ) + XCTAssertEqual( + model.first.startedCount, + 1, + "SwiftUI updates must preserve the mounted view identity." + ) + XCTAssertEqual(model.second.startedCount, 1) + XCTAssertEqual(model.global.startedCount, 1) + + await removeContent( + from: harness, + assertingTeardownOf: model + ) + } + + func testGlobalInvalidationRerendersBothHostedSubtrees() async { + let context = await makeMountedContext(mode: .runtime) + let firstBaseline = context.probe.renderCount(for: .first) + let secondBaseline = context.probe.renderCount(for: .second) + let firstRendered = expectation( + description: "First field globally invalidated" + ) + let secondRendered = expectation( + description: "Second field globally invalidated" + ) + context.probe.onNextRender(of: .first) { + firstRendered.fulfill() + } + context.probe.onNextRender(of: .second) { + secondRendered.fulfill() + } + + context.model.global.emit(1) + + await fulfillment( + of: [firstRendered, secondRendered], + timeout: 2 + ) + XCTAssertGreaterThan( + context.probe.renderCount(for: .first), + firstBaseline + ) + XCTAssertGreaterThan( + context.probe.renderCount(for: .second), + secondBaseline + ) + + await removeContent( + from: context.harness, + assertingTeardownOf: context.model + ) + } + + func testForcedObservableObjectFallbackRerendersBothHostedSubtrees() async { + let context = await makeMountedContext(mode: .forcedFallback) + let firstBaseline = context.probe.renderCount(for: .first) + let secondBaseline = context.probe.renderCount(for: .second) + let firstRendered = expectation( + description: "Fallback rerendered first field" + ) + let secondRendered = expectation( + description: "Fallback rerendered second field" + ) + context.probe.onNextRender(of: .first) { + firstRendered.fulfill() + } + context.probe.onNextRender(of: .second) { + secondRendered.fulfill() + } + + context.model.second.emit(1) + + await fulfillment( + of: [firstRendered, secondRendered], + timeout: 2 + ) + XCTAssertGreaterThan( + context.probe.renderCount(for: .first), + firstBaseline, + "The ObservableObject fallback intentionally invalidates globally." + ) + XCTAssertGreaterThan( + context.probe.renderCount(for: .second), + secondBaseline + ) + + await removeContent( + from: context.harness, + assertingTeardownOf: context.model + ) + } + + func testRuntimeFallbackRerendersBothHostedSubtrees() async throws { + guard !supportsFieldLevelObservation else { + throw XCTSkip( + "Run this contract test on an iOS 15 or iOS 16 simulator." + ) + } + + let context = await makeMountedContext(mode: .runtime) + let firstBaseline = context.probe.renderCount(for: .first) + let secondBaseline = context.probe.renderCount(for: .second) + let firstRendered = expectation( + description: "Runtime fallback rerendered first field" + ) + let secondRendered = expectation( + description: "Runtime fallback rerendered second field" + ) + context.probe.onNextRender(of: .first) { + firstRendered.fulfill() + } + context.probe.onNextRender(of: .second) { + secondRendered.fulfill() + } + + context.model.second.emit(1) + + await fulfillment( + of: [firstRendered, secondRendered], + timeout: 2 + ) + XCTAssertGreaterThan( + context.probe.renderCount(for: .first), + firstBaseline + ) + XCTAssertGreaterThan( + context.probe.renderCount(for: .second), + secondBaseline + ) + + await removeContent( + from: context.harness, + assertingTeardownOf: context.model + ) + } + + private var supportsFieldLevelObservation: Bool { + if #available(iOS 17, macOS 14, *) { + return true + } + return false + } + + private func makeMountedContext( + mode: HostingObservationMode + ) async -> HostingContext { + let model = HostingModel() + let probe = HostingRenderProbe() + let firstMounted = expectation(description: "First field mounted") + let secondMounted = expectation(description: "Second field mounted") + probe.onNextRender(of: .first) { + firstMounted.fulfill() + } + probe.onNextRender(of: .second) { + secondMounted.fulfill() + } + let harness = AppleHostingHarness( + rootView: HostingRoot( + model: model, + probe: probe, + mode: mode + ) + ) + + await fulfillment( + of: [firstMounted, secondMounted], + timeout: 2 + ) + return HostingContext( + model: model, + probe: probe, + harness: harness + ) + } + + private func removeContent( + from harness: AppleHostingHarness, + assertingTeardownOf model: HostingModel + ) async { + let observationsStopped = expectation( + description: "All hosted observation sources stopped" + ) + observationsStopped.expectedFulfillmentCount = 3 + model.first.onStop = { + observationsStopped.fulfill() + } + model.second.onStop = { + observationsStopped.fulfill() + } + model.global.onStop = { + observationsStopped.fulfill() + } + + harness.removeContent() + + await fulfillment(of: [observationsStopped], timeout: 2) + for signal in [model.first, model.second, model.global] { + XCTAssertEqual(signal.activeObserverCount, 0) + XCTAssertEqual(signal.startedCount, 1) + XCTAssertEqual(signal.stoppedCount, 1) + } + } +} + +@MainActor +private struct HostingContext { + let model: HostingModel + let probe: HostingRenderProbe + let harness: AppleHostingHarness +} + +private enum HostedField: Hashable { + case first + case second +} + +private enum HostingObservationMode { + case runtime + case forcedFallback +} + +@MainActor +private final class HostingRenderProbe { + private var counts: [HostedField: Int] = [:] + private var nextRender: [HostedField: () -> Void] = [:] + + func renderCount(for field: HostedField) -> Int { + counts[field, default: 0] + } + + func onNextRender( + of field: HostedField, + perform action: @escaping () -> Void + ) { + precondition(nextRender[field] == nil) + nextRender[field] = action + } + + func record(_ field: HostedField, value: Int) -> String { + counts[field, default: 0] += 1 + let action = nextRender.removeValue(forKey: field) + action?() + return "\(value)" + } +} + +private final class HostingSignal: + KMPValueProperty, + @unchecked Sendable +{ + private let lock = NSLock() + private var currentValue: Int + @MainActor private var observers: [UInt: KMPObservationNotify] = [:] + @MainActor private var nextObserverID: UInt = 0 + @MainActor private(set) var startedCount = 0 + @MainActor private(set) var stoppedCount = 0 + @MainActor var onStop: (() -> Void)? + + var value: Int { + lock.withLock { + currentValue + } + } + + @MainActor + var activeObserverCount: Int { + observers.count + } + + init(_ value: Int) { + currentValue = value + } + + @MainActor + func observe( + _ notify: @escaping KMPObservationNotify + ) -> KMPObservation { + nextObserverID &+= 1 + let observerID = nextObserverID + observers[observerID] = notify + startedCount += 1 + + return KMPObservation { [weak self] in + MainActor.assumeIsolated { + self?.removeObserver(observerID) + } + } + } + + @MainActor + func emit(_ value: Int) { + lock.withLock { + currentValue = value + } + let callbacks = Array(observers.values) + callbacks.forEach { $0() } + } + + @MainActor + private func removeObserver(_ observerID: UInt) { + guard observers.removeValue(forKey: observerID) != nil else { + return + } + stoppedCount += 1 + onStop?() + } +} + +@MainActor +private final class HostingModel: KMPStaticallyObservable { + let first = HostingSignal(0) + let second = HostingSignal(0) + let global = HostingSignal(0) + + static var kmpObservationPlan: KMPObservationPlan { + KMPObservationPlan( + KMPState(dependency: .field(\HostingModel.first)) { + model, notify, _ in + model.first.observe(notify) + }, + KMPState(dependency: .field(\HostingModel.second)) { + model, notify, _ in + model.second.observe(notify) + }, + KMPState(dependency: .global) { model, notify, _ in + model.global.observe(notify) + } + ) + } + + static func kmpStartObservation( + on model: HostingModel, + notify: @escaping KMPObservationNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + kmpObservationPlan.startObservation( + on: model, + notify: notify, + reportError: reportError + ) + } + + static func kmpStartObservation( + on model: HostingModel, + notifyDependency: @escaping KMPObservationDependencyNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + kmpObservationPlan.observeDependencies( + on: model, + notifyDependency: notifyDependency, + reportError: reportError + ) + } +} + +@MainActor +private struct HostingRoot: View { + let model: HostingModel + let probe: HostingRenderProbe + let mode: HostingObservationMode + + var body: some View { + VStack { + field(.first) + field(.second) + } + } + + @ViewBuilder + private func field(_ field: HostedField) -> some View { + switch mode { + case .runtime: + HostedProjectedField( + model: model, + field: field, + probe: probe + ) + case .forcedFallback: + FallbackHostedProjectedField( + model: model, + field: field, + probe: probe + ) + } + } +} + +@MainActor +private struct HostedProjectedField: View { + @KMPObservedObject private var model: HostingModel + let field: HostedField + let probe: HostingRenderProbe + + init( + model: HostingModel, + field: HostedField, + probe: HostingRenderProbe + ) { + _model = KMPObservedObject( + model, + updatePolicy: .immediate, + failurePolicy: .ignore + ) + self.field = field + self.probe = probe + } + + var body: some View { + let value: Int + switch field { + case .first: + value = $model.first + case .second: + value = $model.second + } + return Text(verbatim: probe.record(field, value: value)) + } +} + +@MainActor +private struct FallbackHostedProjectedField: View { + @StateObject private var storage: KMPViewModelStore + let field: HostedField + let probe: HostingRenderProbe + + init( + model: HostingModel, + field: HostedField, + probe: HostingRenderProbe + ) { + _storage = StateObject( + wrappedValue: KMPViewModelStore( + model, + source: .staticPlan(HostingModel.kmpObservationPlan), + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false, + modernObservationEnabled: false + ) + ) + self.field = field + self.probe = probe + } + + var body: some View { + let value: Int + switch field { + case .first: + value = storage.first + case .second: + value = storage.second + } + return Text(verbatim: probe.record(field, value: value)) + } +} + +@MainActor +private final class AppleHostingHarness { + #if os(iOS) + private let window: UIWindow + private let controller: UIHostingController + #elseif os(macOS) + private let window: NSWindow + private let controller: NSHostingController + #endif + + init(rootView: Content) { + #if os(iOS) + controller = UIHostingController(rootView: AnyView(rootView)) + window = UIWindow( + frame: CGRect(x: 0, y: 0, width: 320, height: 480) + ) + window.rootViewController = controller + window.makeKeyAndVisible() + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + #elseif os(macOS) + _ = NSApplication.shared + controller = NSHostingController(rootView: AnyView(rootView)) + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 480), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentViewController = controller + window.orderFrontRegardless() + controller.view.needsLayout = true + controller.view.layoutSubtreeIfNeeded() + #endif + } + + func removeContent() { + controller.rootView = AnyView(EmptyView()) + #if os(iOS) + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + window.rootViewController = nil + window.isHidden = true + #elseif os(macOS) + controller.view.needsLayout = true + controller.view.layoutSubtreeIfNeeded() + window.contentViewController = nil + window.orderOut(nil) + #endif + } +} +#endif From 0d624011763d9279103b52dc7dc17a97d4e5e29b Mon Sep 17 00:00:00 2001 From: sonmbol Date: Tue, 4 Aug 2026 12:04:39 +0400 Subject: [PATCH 2/2] test: harden SwiftUI hosting coverage --- .../AppleHostingHarness.swift | 77 +++++ .../HostingObservationFixture.swift | 157 +++++++++ .../HostingRenderViews.swift | 105 ++++++ ...PObservableBridgeSwiftUIHostingTests.swift | 318 +----------------- 4 files changed, 345 insertions(+), 312 deletions(-) create mode 100644 Tests/KMPObservableBridgeTests/AppleHostingHarness.swift create mode 100644 Tests/KMPObservableBridgeTests/HostingObservationFixture.swift create mode 100644 Tests/KMPObservableBridgeTests/HostingRenderViews.swift diff --git a/Tests/KMPObservableBridgeTests/AppleHostingHarness.swift b/Tests/KMPObservableBridgeTests/AppleHostingHarness.swift new file mode 100644 index 0000000..e5b4cdb --- /dev/null +++ b/Tests/KMPObservableBridgeTests/AppleHostingHarness.swift @@ -0,0 +1,77 @@ +#if os(iOS) || os(macOS) +import SwiftUI + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +@MainActor +final class AppleHostingHarness { + #if os(iOS) + private let window: UIWindow + private let controller: UIHostingController + #elseif os(macOS) + private let window: NSWindow + private let controller: NSHostingController + #endif + + init(rootView: Content) { + #if os(iOS) + controller = UIHostingController(rootView: AnyView(rootView)) + window = UIWindow( + frame: CGRect(x: 0, y: 0, width: 320, height: 480) + ) + window.rootViewController = controller + window.makeKeyAndVisible() + layoutIfNeeded() + #elseif os(macOS) + _ = NSApplication.shared + controller = NSHostingController(rootView: AnyView(rootView)) + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 480), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentViewController = controller + window.orderFrontRegardless() + layoutIfNeeded() + #endif + } + + func flushPendingUpdates() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { [self] in + layoutIfNeeded() + DispatchQueue.main.async { + continuation.resume() + } + } + } + } + + func removeContent() { + controller.rootView = AnyView(EmptyView()) + layoutIfNeeded() + #if os(iOS) + window.rootViewController = nil + window.isHidden = true + #elseif os(macOS) + window.contentViewController = nil + window.orderOut(nil) + #endif + } + + private func layoutIfNeeded() { + #if os(iOS) + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + #elseif os(macOS) + controller.view.needsLayout = true + controller.view.layoutSubtreeIfNeeded() + #endif + } +} +#endif diff --git a/Tests/KMPObservableBridgeTests/HostingObservationFixture.swift b/Tests/KMPObservableBridgeTests/HostingObservationFixture.swift new file mode 100644 index 0000000..8f04664 --- /dev/null +++ b/Tests/KMPObservableBridgeTests/HostingObservationFixture.swift @@ -0,0 +1,157 @@ +#if os(iOS) || os(macOS) +import Foundation +@testable import KMPObservableBridge + +enum HostedField: Hashable { + case first + case second +} + +enum HostingObservationMode { + case runtime + case forcedFallback +} + +@MainActor +struct HostingContext { + let model: HostingModel + let probe: HostingRenderProbe + let harness: AppleHostingHarness +} + +@MainActor +final class HostingRenderProbe { + private var counts: [HostedField: Int] = [:] + private var nextRender: [HostedField: () -> Void] = [:] + + func renderCount(for field: HostedField) -> Int { + counts[field, default: 0] + } + + func onNextRender( + of field: HostedField, + perform action: @escaping () -> Void + ) { + precondition(nextRender[field] == nil) + nextRender[field] = action + } + + func record(_ field: HostedField, value: Int) -> String { + counts[field, default: 0] += 1 + let action = nextRender.removeValue(forKey: field) + action?() + return "\(value)" + } +} + +final class HostingSignal: + KMPValueProperty, + @unchecked Sendable +{ + private let lock = NSLock() + private var currentValue: Int + @MainActor private var observers: [UInt: KMPObservationNotify] = [:] + @MainActor private var nextObserverID: UInt = 0 + @MainActor private(set) var startedCount = 0 + @MainActor private(set) var stoppedCount = 0 + @MainActor var onStop: (() -> Void)? + + var value: Int { + lock.withLock { + currentValue + } + } + + @MainActor + var activeObserverCount: Int { + observers.count + } + + init(_ value: Int) { + currentValue = value + } + + @MainActor + func observe( + _ notify: @escaping KMPObservationNotify + ) -> KMPObservation { + nextObserverID &+= 1 + let observerID = nextObserverID + observers[observerID] = notify + startedCount += 1 + + return KMPObservation { [weak self] in + MainActor.assumeIsolated { + self?.removeObserver(observerID) + } + } + } + + @MainActor + func emit(_ value: Int) { + lock.withLock { + currentValue = value + } + let callbacks = Array(observers.values) + callbacks.forEach { $0() } + } + + @MainActor + private func removeObserver(_ observerID: UInt) { + guard observers.removeValue(forKey: observerID) != nil else { + return + } + stoppedCount += 1 + let action = onStop + onStop = nil + action?() + } +} + +@MainActor +final class HostingModel: KMPStaticallyObservable { + let first = HostingSignal(0) + let second = HostingSignal(0) + let global = HostingSignal(0) + + static var kmpObservationPlan: KMPObservationPlan { + KMPObservationPlan( + KMPState(dependency: .field(\HostingModel.first)) { + model, notify, _ in + model.first.observe(notify) + }, + KMPState(dependency: .field(\HostingModel.second)) { + model, notify, _ in + model.second.observe(notify) + }, + KMPState(dependency: .global) { model, notify, _ in + model.global.observe(notify) + } + ) + } + + static func kmpStartObservation( + on model: HostingModel, + notify: @escaping KMPObservationNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + kmpObservationPlan.startObservation( + on: model, + notify: notify, + reportError: reportError + ) + } + + static func kmpStartObservation( + on model: HostingModel, + notifyDependency: @escaping KMPObservationDependencyNotify, + reportError: @escaping KMPObservationErrorHandler + ) -> KMPObservation { + kmpObservationPlan.observeDependencies( + on: model, + notifyDependency: notifyDependency, + reportError: reportError + ) + } +} +#endif diff --git a/Tests/KMPObservableBridgeTests/HostingRenderViews.swift b/Tests/KMPObservableBridgeTests/HostingRenderViews.swift new file mode 100644 index 0000000..0acbd3d --- /dev/null +++ b/Tests/KMPObservableBridgeTests/HostingRenderViews.swift @@ -0,0 +1,105 @@ +#if os(iOS) || os(macOS) +import SwiftUI +@testable import KMPObservableBridge + +@MainActor +struct HostingRoot: View { + let model: HostingModel + let probe: HostingRenderProbe + let mode: HostingObservationMode + + var body: some View { + VStack { + field(.first) + field(.second) + } + } + + @ViewBuilder + private func field(_ field: HostedField) -> some View { + switch mode { + case .runtime: + HostedProjectedField( + model: model, + field: field, + probe: probe + ) + case .forcedFallback: + FallbackHostedProjectedField( + model: model, + field: field, + probe: probe + ) + } + } +} + +@MainActor +private struct HostedProjectedField: View { + @KMPObservedObject private var model: HostingModel + let field: HostedField + let probe: HostingRenderProbe + + init( + model: HostingModel, + field: HostedField, + probe: HostingRenderProbe + ) { + _model = KMPObservedObject( + model, + updatePolicy: .immediate, + failurePolicy: .ignore + ) + self.field = field + self.probe = probe + } + + var body: some View { + let value: Int + switch field { + case .first: + value = $model.first + case .second: + value = $model.second + } + return Text(verbatim: probe.record(field, value: value)) + } +} + +@MainActor +private struct FallbackHostedProjectedField: View { + @StateObject private var storage: KMPViewModelStore + let field: HostedField + let probe: HostingRenderProbe + + init( + model: HostingModel, + field: HostedField, + probe: HostingRenderProbe + ) { + _storage = StateObject( + wrappedValue: KMPViewModelStore( + model, + source: .staticPlan(HostingModel.kmpObservationPlan), + updatePolicy: .immediate, + failurePolicy: .ignore, + ownsModel: false, + modernObservationEnabled: false + ) + ) + self.field = field + self.probe = probe + } + + var body: some View { + let value: Int + switch field { + case .first: + value = storage.first + case .second: + value = storage.second + } + return Text(verbatim: probe.record(field, value: value)) + } +} +#endif diff --git a/Tests/KMPObservableBridgeTests/KMPObservableBridgeSwiftUIHostingTests.swift b/Tests/KMPObservableBridgeTests/KMPObservableBridgeSwiftUIHostingTests.swift index b7122c2..fd7b462 100644 --- a/Tests/KMPObservableBridgeTests/KMPObservableBridgeSwiftUIHostingTests.swift +++ b/Tests/KMPObservableBridgeTests/KMPObservableBridgeSwiftUIHostingTests.swift @@ -3,12 +3,6 @@ import SwiftUI import XCTest @testable import KMPObservableBridge -#if os(iOS) -import UIKit -#elseif os(macOS) -import AppKit -#endif - @MainActor final class KMPObservableBridgeSwiftUIHostingTests: XCTestCase { func testObservationKeepsUnrelatedHostedSubtreeStable() async throws { @@ -36,9 +30,12 @@ final class KMPObservableBridgeSwiftUIHostingTests: XCTestCase { ) guard supportsFieldLevelObservation else { - harness.removeContent() + await removeContent( + from: harness, + assertingTeardownOf: model + ) throw XCTSkip( - "Field isolation requires iOS 17 or macOS 14." + "Field isolation requires iOS 17+ or macOS 14+." ) } @@ -57,6 +54,7 @@ final class KMPObservableBridgeSwiftUIHostingTests: XCTestCase { model.second.emit(1) await fulfillment(of: [secondRendered], timeout: 2) + await harness.flushPendingUpdates() XCTAssertEqual( probe.renderCount(for: .first), firstBaseline, @@ -268,308 +266,4 @@ final class KMPObservableBridgeSwiftUIHostingTests: XCTestCase { } } } - -@MainActor -private struct HostingContext { - let model: HostingModel - let probe: HostingRenderProbe - let harness: AppleHostingHarness -} - -private enum HostedField: Hashable { - case first - case second -} - -private enum HostingObservationMode { - case runtime - case forcedFallback -} - -@MainActor -private final class HostingRenderProbe { - private var counts: [HostedField: Int] = [:] - private var nextRender: [HostedField: () -> Void] = [:] - - func renderCount(for field: HostedField) -> Int { - counts[field, default: 0] - } - - func onNextRender( - of field: HostedField, - perform action: @escaping () -> Void - ) { - precondition(nextRender[field] == nil) - nextRender[field] = action - } - - func record(_ field: HostedField, value: Int) -> String { - counts[field, default: 0] += 1 - let action = nextRender.removeValue(forKey: field) - action?() - return "\(value)" - } -} - -private final class HostingSignal: - KMPValueProperty, - @unchecked Sendable -{ - private let lock = NSLock() - private var currentValue: Int - @MainActor private var observers: [UInt: KMPObservationNotify] = [:] - @MainActor private var nextObserverID: UInt = 0 - @MainActor private(set) var startedCount = 0 - @MainActor private(set) var stoppedCount = 0 - @MainActor var onStop: (() -> Void)? - - var value: Int { - lock.withLock { - currentValue - } - } - - @MainActor - var activeObserverCount: Int { - observers.count - } - - init(_ value: Int) { - currentValue = value - } - - @MainActor - func observe( - _ notify: @escaping KMPObservationNotify - ) -> KMPObservation { - nextObserverID &+= 1 - let observerID = nextObserverID - observers[observerID] = notify - startedCount += 1 - - return KMPObservation { [weak self] in - MainActor.assumeIsolated { - self?.removeObserver(observerID) - } - } - } - - @MainActor - func emit(_ value: Int) { - lock.withLock { - currentValue = value - } - let callbacks = Array(observers.values) - callbacks.forEach { $0() } - } - - @MainActor - private func removeObserver(_ observerID: UInt) { - guard observers.removeValue(forKey: observerID) != nil else { - return - } - stoppedCount += 1 - onStop?() - } -} - -@MainActor -private final class HostingModel: KMPStaticallyObservable { - let first = HostingSignal(0) - let second = HostingSignal(0) - let global = HostingSignal(0) - - static var kmpObservationPlan: KMPObservationPlan { - KMPObservationPlan( - KMPState(dependency: .field(\HostingModel.first)) { - model, notify, _ in - model.first.observe(notify) - }, - KMPState(dependency: .field(\HostingModel.second)) { - model, notify, _ in - model.second.observe(notify) - }, - KMPState(dependency: .global) { model, notify, _ in - model.global.observe(notify) - } - ) - } - - static func kmpStartObservation( - on model: HostingModel, - notify: @escaping KMPObservationNotify, - reportError: @escaping KMPObservationErrorHandler - ) -> KMPObservation { - kmpObservationPlan.startObservation( - on: model, - notify: notify, - reportError: reportError - ) - } - - static func kmpStartObservation( - on model: HostingModel, - notifyDependency: @escaping KMPObservationDependencyNotify, - reportError: @escaping KMPObservationErrorHandler - ) -> KMPObservation { - kmpObservationPlan.observeDependencies( - on: model, - notifyDependency: notifyDependency, - reportError: reportError - ) - } -} - -@MainActor -private struct HostingRoot: View { - let model: HostingModel - let probe: HostingRenderProbe - let mode: HostingObservationMode - - var body: some View { - VStack { - field(.first) - field(.second) - } - } - - @ViewBuilder - private func field(_ field: HostedField) -> some View { - switch mode { - case .runtime: - HostedProjectedField( - model: model, - field: field, - probe: probe - ) - case .forcedFallback: - FallbackHostedProjectedField( - model: model, - field: field, - probe: probe - ) - } - } -} - -@MainActor -private struct HostedProjectedField: View { - @KMPObservedObject private var model: HostingModel - let field: HostedField - let probe: HostingRenderProbe - - init( - model: HostingModel, - field: HostedField, - probe: HostingRenderProbe - ) { - _model = KMPObservedObject( - model, - updatePolicy: .immediate, - failurePolicy: .ignore - ) - self.field = field - self.probe = probe - } - - var body: some View { - let value: Int - switch field { - case .first: - value = $model.first - case .second: - value = $model.second - } - return Text(verbatim: probe.record(field, value: value)) - } -} - -@MainActor -private struct FallbackHostedProjectedField: View { - @StateObject private var storage: KMPViewModelStore - let field: HostedField - let probe: HostingRenderProbe - - init( - model: HostingModel, - field: HostedField, - probe: HostingRenderProbe - ) { - _storage = StateObject( - wrappedValue: KMPViewModelStore( - model, - source: .staticPlan(HostingModel.kmpObservationPlan), - updatePolicy: .immediate, - failurePolicy: .ignore, - ownsModel: false, - modernObservationEnabled: false - ) - ) - self.field = field - self.probe = probe - } - - var body: some View { - let value: Int - switch field { - case .first: - value = storage.first - case .second: - value = storage.second - } - return Text(verbatim: probe.record(field, value: value)) - } -} - -@MainActor -private final class AppleHostingHarness { - #if os(iOS) - private let window: UIWindow - private let controller: UIHostingController - #elseif os(macOS) - private let window: NSWindow - private let controller: NSHostingController - #endif - - init(rootView: Content) { - #if os(iOS) - controller = UIHostingController(rootView: AnyView(rootView)) - window = UIWindow( - frame: CGRect(x: 0, y: 0, width: 320, height: 480) - ) - window.rootViewController = controller - window.makeKeyAndVisible() - controller.view.setNeedsLayout() - controller.view.layoutIfNeeded() - #elseif os(macOS) - _ = NSApplication.shared - controller = NSHostingController(rootView: AnyView(rootView)) - window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 480), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - window.contentViewController = controller - window.orderFrontRegardless() - controller.view.needsLayout = true - controller.view.layoutSubtreeIfNeeded() - #endif - } - - func removeContent() { - controller.rootView = AnyView(EmptyView()) - #if os(iOS) - controller.view.setNeedsLayout() - controller.view.layoutIfNeeded() - window.rootViewController = nil - window.isHidden = true - #elseif os(macOS) - controller.view.needsLayout = true - controller.view.layoutSubtreeIfNeeded() - window.contentViewController = nil - window.orderOut(nil) - #endif - } -} #endif