Skip to content

Commit 33ef0d7

Browse files
0xLeifclaude
andcommitted
Support WebAssembly + fix Linux collection-cast crash
- Guard @published / ObservableObject with #if canImport(Combine) instead of #if !os(Linux) && !os(Windows). The latter includes os(WASI), so wasm took the Combine path and failed to build (unknown attribute 'Published'). canImport(Combine) correctly excludes Linux, Windows, AND wasm. (AppState#149) - Dictionary.get(_:as:): box through Any before the conditional cast ((self[key] as Any) as? Item). Casting Optional<Any> -> [ConcreteType] takes the runtime _arrayForceCast path, which aborts (swift_dynamicCastFailure) on Linux/WASI for collection-typed values. (AppState#151) - Add array round-trip regression tests through Cache<String, Any>. 178 tests pass on macOS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ff7c208 commit 33ef0d7

3 files changed

Lines changed: 46 additions & 5 deletions

File tree

Sources/Cache/Cache/Cache.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@ open class Cache<Key: Hashable, Value>: Cacheable, @unchecked Sendable {
1919
/// Using NSRecursiveLock to prevent re-entrant lock deadlocks with @Published property wrapper
2020
fileprivate var lock: NSRecursiveLock
2121

22-
#if os(Linux) || os(Windows)
23-
fileprivate var cache: [Key: Value] = [:]
24-
#else
22+
#if canImport(Combine)
2523
/// The actual cache dictionary of key-value pairs.
2624
@Published fileprivate var cache: [Key: Value] = [:]
25+
#else
26+
// Combine (and therefore `@Published`) is unavailable on Linux, Windows, and
27+
// WebAssembly (`os(WASI)`). Guarding on `canImport(Combine)` — rather than
28+
// `!os(Linux) && !os(Windows)` — correctly excludes wasm too.
29+
fileprivate var cache: [Key: Value] = [:]
2730
#endif
2831

2932
/**
@@ -160,7 +163,7 @@ open class Cache<Key: Hashable, Value>: Cacheable, @unchecked Sendable {
160163
}
161164
}
162165

163-
#if !os(Linux) && !os(Windows)
166+
#if canImport(Combine)
164167
extension Cache: ObservableObject { }
165168
#endif
166169

Sources/Cache/Dictionary/Dictionary+Cacheable.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ extension Dictionary: Cacheable {
1515
- Returns: The casted value for the given key, or `nil` if the key doesn't exist or the cast fails.
1616
*/
1717
public func get<Item>(_ key: Key, as: Item.Type = Item.self) -> Item? {
18-
guard let value = self[key] as? Item else {
18+
// Route through an explicit `Any` boxing step before the conditional cast. This avoids
19+
// `swift_dynamicCastFailure` / `_arrayForceCast` aborts on Linux and WASI when casting an
20+
// `Optional<Any>` that wraps a collection type (e.g. `[SomeStruct]`): without the
21+
// intermediate cast, the runtime takes the `Optional<Any>` → `[T]` forced-array-cast path,
22+
// which is not implemented the same way off Darwin. Boxing to `Any` first normalises the
23+
// dynamic type to the concrete `Item` path.
24+
guard let value = (self[key] as Any) as? Item else {
1925
return nil
2026
}
2127

Tests/CacheTests/CacheTests.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,4 +287,36 @@ final class CacheTests: XCTestCase {
287287

288288
XCTAssertEqual(cache[.text, default: "missing value"], "missing value")
289289
}
290+
291+
// MARK: - Issue #151 — collection-typed values through an `Any` cache
292+
293+
private struct Point: Equatable {
294+
let x: Int
295+
let y: Int
296+
}
297+
298+
func testArrayOfStructsRoundTripThroughAnyCache() {
299+
let cache: Cache<String, Any> = Cache()
300+
let points = [Point(x: 1, y: 2), Point(x: 3, y: 4)]
301+
cache.set(value: points, forKey: "points")
302+
303+
let resolved: [Point]? = cache.get("points", as: [Point].self)
304+
XCTAssertEqual(resolved, points)
305+
}
306+
307+
func testArrayOfStringsRoundTripThroughAnyCache() {
308+
let cache: Cache<String, Any> = Cache()
309+
cache.set(value: ["a", "b", "c"], forKey: "letters")
310+
311+
XCTAssertEqual(cache.get("letters", as: [String].self), ["a", "b", "c"])
312+
}
313+
314+
func testArrayResolveRoundTripThroughAnyCache() throws {
315+
let cache: Cache<String, Any> = Cache()
316+
let values = [10, 20, 30]
317+
cache.set(value: values, forKey: "values")
318+
319+
let resolved: [Int] = try cache.resolve("values", as: [Int].self)
320+
XCTAssertEqual(resolved, values)
321+
}
290322
}

0 commit comments

Comments
 (0)