Skip to content

Commit 515e7c3

Browse files
0xLeifclaudecursoragent
authored
3.0.0 release readiness: cross-platform Observation, Cache pin, docs (#153)
* Cross-platform observation delivery + WebAssembly build support #149 (WASM): switch Combine/OSLog/SwiftUI-gated #if !os(Linux) && !os(Windows) guards to their canImport(...) checks. os(WASI) is neither Linux nor Windows, so the old guards pulled Combine/SwiftUI into the wasm build. Keychain/iCloud stay Apple-only. #150 (observation off Apple): delivery was bridged through Combine's consume(object: cache) (Apple-only), so withObservationTracking never fired on Linux/Windows/wasm. Now every mutation path (State/StoredState/FileState/SyncState/ SecureState/DependencySlice) calls notifyChange() directly on all platforms, and the Combine cache bridge is removed so Apple still fires exactly once. 163 tests pass on macOS. Observation tests stay gated off Linux/Windows: swift-corelibs -xctest cannot discover synchronous @mainactor test methods there (it force-casts test thunks to () -> () and aborts) — a test-harness limitation, not a runtime one. The #150 delivery code itself is cross-platform. Full wasm build + Linux collection-typed state also need the Cache fix (0xLeif/Cache#30). Addresses #149, #150. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Prepare 3.0.0 release: pure Observation delivery, Cache pin, docs - Drop the unused Combine cache bridge from Application; document that notifyChange() must stay synchronous on the main thread - Add CrossPlatformObservationTests (async @mainactor) so Linux/Windows CI exercises Observation delivery; keep the fuller Apple-gated suites - Gate ObservedDependency on canImport(SwiftUI) for WebAssembly - Pin Cache to the 0xLeif/Cache#30 commit for WASM + Linux collection casts - Point installation guides at 3.0.0; remove stale Examples fledge lane Co-authored-by: Leif <leif.algo@pm.me> * Fix CrossPlatformObservationTests: mutate via local var state Application.state(...).value is an immutable rvalue; assign through a mutable local like the other AppStateTests. Co-authored-by: Leif <leif.algo@pm.me> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 4325877 commit 515e7c3

30 files changed

Lines changed: 176 additions & 110 deletions

Package.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ let package = Package(
2626
)
2727
],
2828
dependencies: [
29-
.package(url: "https://github.com/0xLeif/Cache", from: "2.0.0"),
29+
// Pin to the Cache commit that includes WebAssembly `canImport(Combine)` guards and the
30+
// Linux/WASI collection-cast fix from 0xLeif/Cache#30. Switch back to a versioned
31+
// `from:` pin once that PR is merged and tagged (e.g. 2.1.3+).
32+
.package(
33+
url: "https://github.com/0xLeif/Cache",
34+
revision: "33ef0d77e144eaab0734b3da02c3fed6629166eb"
35+
),
3036
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.0")
3137
],
3238
targets: [

Sources/AppState/Application/Application+public.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Foundation
2-
#if !os(Linux) && !os(Windows)
2+
#if canImport(SwiftUI)
33
import SwiftUI
44
#endif
55

@@ -387,7 +387,7 @@ public extension Application {
387387
}
388388
}
389389

390-
#if !os(Linux) && !os(Windows)
390+
#if canImport(SwiftUI)
391391
// MARK: - SwiftUI Preview Dependency Functions
392392

393393
public extension Application {

Sources/AppState/Application/Application.swift

Lines changed: 14 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import Cache
2+
import Foundation
23
import Observation
3-
#if !os(Linux) && !os(Windows)
4-
import Combine
4+
#if canImport(OSLog)
55
import OSLog
6-
#else
7-
import Foundation
86
#endif
97

108
/// `Application` is a class that can be observed for changes, keeping track of the states within the application.
@@ -14,7 +12,7 @@ open class Application: NSObject {
1412
@MainActor
1513
static var shared: Application = Application()
1614

17-
#if !os(Linux) && !os(Windows)
15+
#if canImport(OSLog)
1816
/// Logger specifically for AppState
1917
public var logger: Dependency<Logger> {
2018
dependency(Logger(subsystem: "AppState", category: "Application"))
@@ -44,21 +42,16 @@ open class Application: NSObject {
4442
/// view body — as dependent on AppState, and mutating it (via ``notifyChange()``) tells those
4543
/// observers to update.
4644
///
45+
/// Delivery is cross-platform: every AppState mutation path calls ``notifyChange()`` directly.
46+
/// There is no Combine/`ObservableObject` bridge involved in Observation delivery.
47+
///
4748
/// Thread-safety: every mutation funnels through ``notifyChange()``, which AppState only invokes
48-
/// from its main-actor setters and from the cache observer below — and that observer fires
49-
/// *synchronously* during those same main-actor cache mutations. Reads occur on the main actor
50-
/// (SwiftUI bodies and the `@MainActor` property wrappers). The mutation itself is applied through
51-
/// the synthesized `@Observable` registrar, which is `Sendable` and internally synchronized.
49+
/// from its main-actor setters (and from `@MainActor` hooks such as
50+
/// ``didChangeExternally(notification:)``). Reads occur on the main actor (SwiftUI bodies and the
51+
/// `@MainActor` property wrappers). The mutation itself is applied through the synthesized
52+
/// `@Observable` registrar, which is `Sendable` and internally synchronized.
5253
private var changeAnchor: Int = 0
5354

54-
#if !os(Linux) && !os(Windows)
55-
/// A set to store cancellables for Combine subscriptions, ensuring they are properly managed and released.
56-
@ObservationIgnored
57-
private var bag: Set<AnyCancellable> = Set()
58-
59-
deinit { bag.removeAll() }
60-
#endif
61-
6255
/// Initializes a new instance of `Application`.
6356
///
6457
/// This initializer is used for the default `Application.shared` instance and any custom `Application` subclasses.
@@ -77,10 +70,6 @@ open class Application: NSObject {
7770

7871
setup(self)
7972
loadDefaultDependencies()
80-
81-
#if !os(Linux) && !os(Windows)
82-
consume(object: cache)
83-
#endif
8473
}
8574

8675
/// Registers the current Observation tracking scope (such as a SwiftUI view body) as dependent on
@@ -106,6 +95,10 @@ open class Application: NSObject {
10695
/// not `Sendable`, so the change cannot be hopped to the main thread on the caller's behalf — the
10796
/// invariant is instead asserted here so off-main misuse surfaces in debug and CI. `Application`'s
10897
/// setters and the `@MainActor` `didChangeExternally(notification:)` override already satisfy it.
98+
///
99+
/// Callers must not wrap this in an unconditional `DispatchQueue.main.async` hop: asynchronous
100+
/// delivery would break the synchronous `withObservationTracking` contract that AppState and
101+
/// SwiftUI rely on when already on the main thread.
109102
public func notifyChange() {
110103
assert(Thread.isMainThread, "Application.notifyChange() must be called on the main thread.")
111104

@@ -151,35 +144,4 @@ open class Application: NSObject {
151144
load(dependency: \.userDefaults)
152145
load(dependency: \.fileManager)
153146
}
154-
155-
#if !os(Linux) && !os(Windows)
156-
/// Consumes changes in the provided ObservableObject and sends updates before the object will change.
157-
///
158-
/// - Parameter object: The ObservableObject to observe
159-
private func consume<Object: ObservableObject>(
160-
object: Object
161-
) {
162-
bag.insert(
163-
object.objectWillChange.sink(
164-
receiveCompletion: { _ in },
165-
receiveValue: { [weak self] _ in
166-
// Deliver synchronously when already on main (preserving the synchronous
167-
// observation contract `withObservationTracking` relies on), and only hop to main
168-
// when a dependency publishes off-main — `notifyChange()` asserts main-thread and
169-
// mutates `changeAnchor`, which must never be touched off the main thread.
170-
if Thread.isMainThread {
171-
self?.notifyChange()
172-
} else {
173-
DispatchQueue.main.async {
174-
MainActor.assumeIsolated {
175-
Application.shared.notifyChange()
176-
}
177-
}
178-
}
179-
}
180-
)
181-
)
182-
}
183-
#endif
184147
}
185-

Sources/AppState/Application/Types/Dependency/Slice/Application+DependencySlice.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@ extension Application.DependencySlice where SliceKeyPath == WritableKeyPath<Valu
3434
public var value: SliceValue {
3535
get { dependency.value[keyPath: keyPath] }
3636
set {
37-
#if !os(Linux) && !os(Windows)
3837
Application.shared.notifyChange()
39-
#endif
4038
dependency.value[keyPath: keyPath] = newValue
4139
}
4240
}

Sources/AppState/Application/Types/Helper/Application+ApplicationPreview.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#if !os(Linux) && !os(Windows)
1+
#if canImport(SwiftUI)
22
import SwiftUI
33

44
extension Application {

Sources/AppState/Application/Types/State/Application+FileState.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ extension Application {
7474
)
7575
}
7676

77-
#if (!os(Linux) && !os(Windows))
77+
#if canImport(ObjectiveC)
7878
if NSClassFromString("XCTest") == nil {
7979
Task {
8080
await MainActor.run {
@@ -149,6 +149,8 @@ extension Application {
149149
)
150150
}
151151
}
152+
153+
shared.notifyChange()
152154
}
153155
}
154156

Sources/AppState/Application/Types/State/Application+State.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ extension Application {
1111
}
1212

1313
public static var emoji: Character {
14-
#if !os(Linux) && !os(Windows)
14+
#if canImport(OSLog)
1515
return "🔄"
1616
#else
1717
return "📦"
@@ -27,6 +27,21 @@ extension Application {
2727
private let initial: Value
2828

2929
/// The current state value.
30+
///
31+
/// - Note: **Issue #151 — Linux `_arrayForceCast` crash.**
32+
/// The `shared.cache.get(scope.key, as: State<Value>.self)` call below performs
33+
/// an `Any → State<Value>` dynamic cast inside `Dictionary+Cacheable.swift:18`
34+
/// (`self[key] as? Item`). On Linux, when `Value` is a collection type such as
35+
/// `[Element]`, the Swift runtime's `swift_dynamicCast` path invokes
36+
/// `_arrayForceCast` for the generic parameter, which crashes with a
37+
/// `swift_dynamicCastFailure`. This is a known Swift-on-Linux stdlib/runtime
38+
/// limitation (SR-4049 / swift#40956) affecting `as?` casts from `Any` to
39+
/// generic structs whose generic parameters are array types. A clean fix requires
40+
/// either: (a) replacing `Cache<String, Any>` with a type-index keyed store so
41+
/// `Any` is never the concrete container value type, or (b) filing a Cache library
42+
/// issue to store a type-erased wrapper that avoids the metatype dereference during
43+
/// cast. Until then, `State<[T]>` and `FileState<[T]?>` will crash on Linux when
44+
/// their value is read after being evicted from the in-memory cache.
3045
@MainActor
3146
public var value: Value {
3247
get {
@@ -47,7 +62,7 @@ extension Application {
4762
forKey: scope.key
4863
)
4964
}
50-
#if (!os(Linux) && !os(Windows))
65+
#if canImport(ObjectiveC)
5166
if NSClassFromString("XCTest") == nil {
5267
Task { @MainActor in
5368
setValue()
@@ -73,6 +88,7 @@ extension Application {
7388
),
7489
forKey: scope.key
7590
)
91+
shared.notifyChange()
7692
}
7793
}
7894

Sources/AppState/Application/Types/State/Application+StoredState.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ extension Application {
8282
userDefaults.set(newValue, forKey: scope.key)
8383
}
8484
}
85+
86+
shared.notifyChange()
8587
}
8688
}
8789

Sources/AppState/Application/Types/State/Application+SyncState.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ extension Application {
105105
)
106106
}
107107
}
108+
109+
shared.notifyChange()
108110
}
109111
}
110112

Sources/AppState/PropertyWrappers/Dependency/ObservedDependency.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#if !os(Linux) && !os(Windows)
1+
#if canImport(SwiftUI)
22
import SwiftUI
33

44
/// The `@ObservedDependency` property wrapper is a feature provided by AppState, intended to simplify dependency handling throughout your application. It makes it easy to access, share, and manage dependencies in a neat and Swift idiomatic way. It works the same as `@AppDependency`, but comes with the power of the `@ObservedObject` property wrapper.

0 commit comments

Comments
 (0)