From 84fe9bbdbc9f456317deeab558df5931623715c2 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Thu, 31 Jul 2025 12:12:59 -0700 Subject: [PATCH 1/7] Squash --- .../Sources/Environment/Cache/CacheKey.swift | 27 ++ .../Environment/Cache/CacheStorage.swift | 89 ++++++ .../Environment/Cache/ValidatingCache.swift | 198 ++++++++++++ .../Sources/Environment/Environment.swift | 159 +++++++++- .../Sources/Internal/Equivalency.swift | 2 +- .../Internal/InternalEnvironmentKey.swift | 13 + BlueprintUI/Sources/Internal/Logger.swift | 172 ++++++++++- .../Tests/EnvironmentEquivalencyTests.swift | 164 ++++++++++ BlueprintUI/Tests/ValidatingCacheTests.swift | 292 ++++++++++++++++++ 9 files changed, 1109 insertions(+), 7 deletions(-) create mode 100644 BlueprintUI/Sources/Environment/Cache/CacheKey.swift create mode 100644 BlueprintUI/Sources/Environment/Cache/CacheStorage.swift create mode 100644 BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift create mode 100644 BlueprintUI/Sources/Internal/InternalEnvironmentKey.swift create mode 100644 BlueprintUI/Tests/EnvironmentEquivalencyTests.swift create mode 100644 BlueprintUI/Tests/ValidatingCacheTests.swift diff --git a/BlueprintUI/Sources/Environment/Cache/CacheKey.swift b/BlueprintUI/Sources/Environment/Cache/CacheKey.swift new file mode 100644 index 000000000..e6fc2667a --- /dev/null +++ b/BlueprintUI/Sources/Environment/Cache/CacheKey.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Types conforming to this protocol can be used as keys in `CacheStorage`. +/// +/// Using a type as the key allows us to strongly type each value, with the +/// key's `CacheKey.Value` associated value. +/// +/// ## Example +/// +/// Usually a key is implemented with an uninhabited type, such an empty enum. +/// +/// enum WidgetCountsKey: CacheKey { +/// static let emptyValue: [WidgetID: Int] = [:] +/// } +/// +/// You can write a small extension on `CacheStorage` to make it easier to use your key. +/// +/// extension CacheStorage { +/// var widgetCounts: [WidgetID: Int] { +/// get { self[WidgetCountsKey.self] } +/// set { self[WidgetCountsKey.self] = newValue } +/// } +/// } +public protocol CacheKey { + associatedtype Value + static var emptyValue: Self.Value { get } +} diff --git a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift new file mode 100644 index 000000000..bdf1b3808 --- /dev/null +++ b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift @@ -0,0 +1,89 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#endif + +/// Environment-associated storage used to cache types used across layout passes (eg, size calculations). +/// The storage itself is type-agnostic, requiring only that its keys and values conform to the `CacheKey` protocol +/// Caches are responsible for managing their own lifetimes and eviction strategies. +@_spi(CacheStorage) public final class CacheStorage: Sendable, CustomDebugStringConvertible { + + // Optional name to distinguish between instances for debugging purposes. + public var name: String? = nil + fileprivate var storage: [ObjectIdentifier: Any] = [:] + + init() { + #if canImport(UIKit) + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.storage.removeAll() + } + #endif + } + + public subscript(key: KeyType.Type) -> KeyType.Value where KeyType: CacheKey { + get { + storage[ObjectIdentifier(key), default: KeyType.emptyValue] as! KeyType.Value + } + set { + storage[ObjectIdentifier(key)] = newValue + } + } + + public var debugDescription: String { + let debugName = if let name { + "CacheStorage (\(name))" + } else { + "CacheStorage" + } + return "\(debugName): \(storage.count) entries" + } + +} + +extension Environment { + + struct CacheStorageEnvironmentKey: InternalEnvironmentKey { + static var defaultValue = CacheStorage() + } + + + @_spi(CacheStorage) public var cacheStorage: CacheStorage { + get { self[CacheStorageEnvironmentKey.self] } + set { self[CacheStorageEnvironmentKey.self] = newValue } + } + +} + +/// A UUID that changes based on value changes of the containing type. +/// Two fingerprinted objects may be quickly compared for equality by comparing their fingerprints. +/// This is roughly analagous to a hash, although with inverted properties: Two objects with the same fingerprint can be trivially considered equal, but two otherwise equal objects may have different fingerprint. +/// - Note: This type is deliberately NOT equatable – this is to prevent accidental inclusion of it when its containing type is equatable. +struct ComparableFingerprint: ContextuallyEquivalent, CustomStringConvertible { + + typealias Value = UUID + + var value: Value + + init() { + value = Value() + } + + mutating func modified() { + value = Value() + } + + /// - Note: This is a duplicate message but: this type is deliberately NOT equatable – this is to prevent accidental inclusion of it when its containing type is equatable. Use this instead. + func isEquivalent(to other: ComparableFingerprint?, in context: EquivalencyContext) -> Bool { + value == other?.value + } + + var description: String { + value.uuidString + } + +} + diff --git a/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift new file mode 100644 index 000000000..b48a14761 --- /dev/null +++ b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift @@ -0,0 +1,198 @@ +import Foundation + +/// Validating cache is a cache which, if it has a value for a key, runs a closure to verify that the cache value is still relevant and not state. +/// This is useful for cases when you might otherwise wish to store the validation data as a key, but it does not conform to Hashable, or its hashability properties do not neccessarily affect the validity of the cached data. +@_spi(CacheStorage) public struct ValidatingCache: Sendable where Key: Hashable { + + private var storage: [Key: ValueStorage] = [:] + + private struct ValueStorage { + let value: Value + let validationData: ValidationData + } + + public init() {} + + /// Retrieves the value for a given key, without evaluating any validation conditions. + public subscript(uncheckedKey key: Key) -> Value? { + storage[key]?.value + } + + /// Retrieves or creates a value based on a key and validation function. + /// - Parameters: + /// - key: The key to look up. + /// - validate: A function that evaluates whether or not a given result is still valid. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + public mutating func retrieveOrCreate( + key: Key, + validate: (ValidationData) -> Bool, + create: () -> (Value, ValidationData) + ) -> Value { + if let valueStorage = storage[key] { + Logger.logValidatingCacheKeyHit(key: key) + let validationToken = Logger.logValidatingCacheValidationStart(key: key) + if validate(valueStorage.validationData) { + Logger.logValidatingCacheHitAndValidationSuccess(key: key) + Logger.logValidatingCacheValidationEnd(validationToken, key: key) + return valueStorage.value + #if DEBUG + // FIXME: WAY TO MAKE SURE THIS DOESN'T SHIP ON. + // Enable this to always evaluate the create block to assert that the caching is producing the expected value. + // if let stored = valueStorage.value as? (any Equatable) { + // let fresh = create().0 as! Equatable + // assert(stored.isEqual(fresh)) + // } + // return valueStorage.value + #endif + } else { + Logger.logValidatingCacheHitAndValidationFailure(key: key) + Logger.logValidatingCacheValidationEnd(validationToken, key: key) + } + } else { + Logger.logValidatingCacheKeyMiss(key: key) + } + let createToken = Logger.logValidatingCacheFreshValueCreationStart(key: key) + let (fresh, validationData) = create() + Logger.logValidatingCacheFreshValueCreationEnd(createToken, key: key) + storage[key] = ValueStorage(value: fresh, validationData: validationData) + return fresh + } + + public mutating func removeValue(forKey key: Key) -> Value? { + storage.removeValue(forKey: key)?.value + } + +} + +/// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned. +@_spi(CacheStorage) public struct EnvironmentValidatingCache: Sendable where Key: Hashable { + + private var backing = ValidatingCache() + + public init() {} + + /// Retrieves or creates a value based on a key and environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment should be evaluated. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + mutating func retrieveOrCreate( + key: Key, + environment: Environment, + context: EquivalencyContext, + create: (Environment) -> Value + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isEquivalent(to: $0, in: context) + } create: { + environment.snapshottingAccess { environment in + create(environment) + } + } + } + +} + +/// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned, and allows for additional data to be stored to be validated. +@_spi(CacheStorage) public struct EnvironmentAndValueValidatingCache: Sendable where Key: Hashable { + + private var backing = ValidatingCache() + + public init() {} + + /// Retrieves or creates a value based on a key and a validation function, alongside environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment should be evaluated. + /// - validate: A function that evaluates whether or not a given result is still valid. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + /// - Note: Generally, prefer the `validationValue` versions of this method if the validation value conforms to ContextuallyEquivalent or Equatable. + mutating func retrieveOrCreate( + key: Key, + environment: Environment, + context: EquivalencyContext, + validate: (AdditionalValidationData) -> Bool, + create: (Environment) -> (Value, AdditionalValidationData) + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isEquivalent(to: $0.0, in: context) && validate($0.1) + } create: { + let ((value, additional), snapshot) = environment.snapshottingAccess { environment in + create(environment) + } + return (value, (snapshot, additional)) + } + } + +} + + +@_spi(CacheStorage) extension EnvironmentAndValueValidatingCache where AdditionalValidationData: ContextuallyEquivalent { + + /// Retrieves or creates a value based on a key and a validation value, alongside environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment and validation values should be evaluated. + /// - validationValue: A value that will be compared using contextual equivalence that evaluates whether or not a given result is still valid. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + public mutating func retrieveOrCreate( + key: Key, + environment: Environment, + validationValue: AdditionalValidationData, + context: EquivalencyContext, + create: (Environment) -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0.isEquivalent(to: validationValue, in: context) + } create: { + (create($0), validationValue) + } + + } + +} + +@_spi(CacheStorage) extension EnvironmentAndValueValidatingCache where AdditionalValidationData: Equatable { + + /// Retrieves or creates a value based on a key and a validation value, alongside environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment should be evaluated. + /// - validationValue: A value that will be compared using strict equality that evaluates whether or not a given result is still valid. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + @_disfavoredOverload public mutating func retrieveOrCreate( + key: Key, + environment: Environment, + validationValue: AdditionalValidationData, + context: EquivalencyContext, + create: (Environment) -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0 == validationValue + } create: { + (create($0), validationValue) + } + } + +} + + +extension Equatable { + + fileprivate func isEqual(_ other: any Equatable) -> Bool { + guard let other = other as? Self else { + return false + } + return self == other + } + +} diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index df407a9d2..621d38ee2 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -40,7 +40,11 @@ public struct Environment { /// Each key will return its default value. public static let empty = Environment() + // Fingerprint used for referencing previously compared environments. + var fingerprint = ComparableFingerprint() + private var values: [Keybox: Any] = [:] + private var snapshotting: SnapshottingEnvironment? private var internalValues: [ObjectIdentifier: Any] = [:] @@ -51,15 +55,21 @@ public struct Environment { } set { let keybox = Keybox(key) + let oldValue = values[keybox] values[keybox] = newValue + fingerprint.modified() } } private subscript(keybox: Keybox) -> Any { - values[keybox, default: keybox.type.defaultValue] + let value = values[keybox, default: keybox.type.defaultValue] + if let snapshotting { + snapshotting.value.values[keybox] = value + } + return value } - public subscript(internal key: Key.Type) -> Key.Value where Key: EnvironmentKey { + subscript(key: Key.Type) -> Key.Value where Key: InternalEnvironmentKey { get { internalValues[ObjectIdentifier(key), default: key.defaultValue] as! Key.Value } @@ -79,23 +89,168 @@ public struct Environment { func merged(prioritizing other: Environment) -> Environment { var merged = self merged.values.merge(other.values) { $1 } + merged.fingerprint.modified() return merged } + func snapshottingAccess(_ closure: (Environment) -> T) -> (T, EnvironmentSnapshot) { + var watching = self + let snapshotting = SnapshottingEnvironment() + watching.snapshotting = snapshotting + let result = closure(watching) + return (result, snapshotting.value) + } + +} + +/// An environment snapshot is immutable copy of the comparable elements of an Environment struct that were accessed during the cached value's creaton.. +struct EnvironmentSnapshot { + + // Fingerprint used for referencing previously compared environments. + var fingerprint: ComparableFingerprint + var values: [Environment.Keybox: Any] } +private final class SnapshottingEnvironment { + var value = EnvironmentSnapshot(fingerprint: .init(), values: [:]) +} + extension Environment: ContextuallyEquivalent { public func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { guard let other else { return false } + if fingerprint.isEquivalent(to: other.fingerprint) { + Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) + return true + } + if let evaluated = cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] { + Logger.logEnvironmentEquivalencyFingerprintCacheHit(environment: self) + return evaluated + } + Logger.logEnvironmentEquivalencyFingerprintCacheMiss(environment: self) + let token = Logger.logEnvironmentEquivalencyComparisonStart(environment: self) let keys = Set(values.keys).union(other.values.keys) for key in keys { guard key.isEquivalent(self[key], other[key], context) else { + cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] = false + Logger.logEnvironmentEquivalencyCompletedWithNonEquivalence( + environment: self, + key: key, + context: context + ) + Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) + return false + } + } + Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) + Logger.logEnvironmentEquivalencyCompletedWithEquivalence(environment: self, context: context) + cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] = true + return true + } + + func isEquivalent(to snapshot: EnvironmentSnapshot?, in context: EquivalencyContext) -> Bool { + guard let snapshot else { return false } + // We don't even need to thaw the environment if the fingerprints match. + if snapshot.fingerprint.isEquivalent(to: fingerprint) { + Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) + return true + } + let scope = Set(snapshot.values.keys.map(\.objectIdentifier)) + if let evaluated = cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] { + Logger.logEnvironmentEquivalencyFingerprintCacheHit(environment: self) + return evaluated + } + Logger.logEnvironmentEquivalencyFingerprintCacheMiss(environment: self) + let token = Logger.logEnvironmentEquivalencyComparisonStart(environment: self) + for (key, value) in snapshot.values { + guard key.isEquivalent(self[key], value, context) else { + cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] = false + Logger.logEnvironmentEquivalencyCompletedWithNonEquivalence( + environment: self, + key: key, + context: context + ) + Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) return false } } + Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) + Logger.logEnvironmentEquivalencyCompletedWithEquivalence(environment: self, context: context) + cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] = true return true + + } + + +} + +extension CacheStorage { + + fileprivate struct EnvironmentFingerprintCache { + + struct Key: Hashable { + let lhs: ComparableFingerprint.Value + let rhs: ComparableFingerprint.Value + let scope: Set? + + init(_ lhs: ComparableFingerprint.Value, _ rhs: ComparableFingerprint.Value, scope: Set?) { + // Sort lhs/rhs so we don't have diff results based on caller. + self.lhs = min(lhs, rhs) + self.rhs = max(lhs, rhs) + self.scope = scope + } + } + + typealias EquivalencyResult = [EquivalencyContext: Bool] + var storage: [Key: [EquivalencyContext: Bool]] = [:] + + public subscript( + lhs: ComparableFingerprint, + rhs: ComparableFingerprint, + context: EquivalencyContext, + scope: Set? = nil + ) -> Bool? { + get { + let key = Key(lhs.value, rhs.value, scope: scope) + if let exact = storage[key]?[context] { + return exact + } else if let allComparisons = storage[key] { + switch context { + case .all: + // If we're checking for equivalency in ALL contexts, we can short circuit based on any case where equivalency is false. + if allComparisons.contains(where: { $1 == false }) { + return false + } else { + return nil + } + case .elementSizing: + // If we've already evaluated it to be equivalent in all cases, we can short circuit because we know that means any more specific checks must also be equivalent + if allComparisons[.all] == true { + return true + } else { + return nil + } + } + } else { + return nil + } + } + set { + storage[Key(lhs.value, rhs.value, scope: scope), default: [:]][context] = newValue + } + } + + } + + /// A cache of previously compared environments and their results. + private struct EnvironmentComparisonCacheKey: CacheKey { + static var emptyValue = EnvironmentFingerprintCache() + } + + fileprivate var environmentComparisonCache: EnvironmentFingerprintCache { + get { self[EnvironmentComparisonCacheKey.self] } + set { self[EnvironmentComparisonCacheKey.self] = newValue } } } diff --git a/BlueprintUI/Sources/Internal/Equivalency.swift b/BlueprintUI/Sources/Internal/Equivalency.swift index 62693f7b2..e7ad0f9a6 100644 --- a/BlueprintUI/Sources/Internal/Equivalency.swift +++ b/BlueprintUI/Sources/Internal/Equivalency.swift @@ -65,7 +65,7 @@ public struct AnyContextuallyEquivalent: ContextuallyEquivalent { public func isEquivalent(to other: AnyContextuallyEquivalent?, in context: EquivalencyContext) -> Bool { guard let base = (base as? any ContextuallyEquivalent) else { return false } - return base.isEquivalent(to: other?.base as? ContextuallyEquivalent, in: context) + return base.isEquivalent(to: other?.base as? any ContextuallyEquivalent, in: context) } } diff --git a/BlueprintUI/Sources/Internal/InternalEnvironmentKey.swift b/BlueprintUI/Sources/Internal/InternalEnvironmentKey.swift new file mode 100644 index 000000000..fdc39922d --- /dev/null +++ b/BlueprintUI/Sources/Internal/InternalEnvironmentKey.swift @@ -0,0 +1,13 @@ +import Foundation + +/// An `EnvironmentKey` which is only stored in the internal storage of the `Environment`, and which does not participate in equivalency comparsions. +protocol InternalEnvironmentKey: EnvironmentKey {} + +extension InternalEnvironmentKey { + + // Internal environment keys do not participate in equivalency checks. + static func isEquivalent(lhs: Value, rhs: Value, in context: EquivalencyContext) -> Bool { + true + } + +} diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index b2074465a..a05acfee9 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -2,12 +2,16 @@ import Foundation import os.log /// Namespace for logging helpers -enum Logger {} +enum Logger { + fileprivate static let signposter = OSSignposter(logHandle: .active) + static var hook: ((String) -> Void)? +} -/// BlueprintView signposts +// MARK: - BlueprintView signposts extension Logger { static func logLayoutStart(view: BlueprintView) { + guard BlueprintLogging.isEnabled else { return } os_signpost( @@ -100,7 +104,8 @@ extension Logger { } } -/// Measuring signposts +// MARK: - HintingSizeCache signposts + extension Logger { static func logMeasureStart(object: AnyObject, description: String, constraint: SizeConstraint) { @@ -185,10 +190,169 @@ extension Logger { ) } - // MARK: Utilities + +} + +// MARK: - CacheStorage + +extension Logger { + + // MARK: Environment Comparison + + static func logEnvironmentKeySetEquivalencyComparisonStart(key: some Hashable) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } + let token = signposter.beginInterval( + "Environment key set equivalency comparison", + id: key.signpost, + "Start: \(String(describing: key))" + ) + hook?("\(#function) \(String(describing: key))") + return token + } + + static func logEnvironmentKeySetEquivalencyComparisonEnd(_ token: OSSignpostIntervalState?, key: some Hashable) { + guard BlueprintLogging.isEnabled, let token else { return } + signposter.endInterval("Environment key set equivalency comparison", token, "\(String(describing: key))") + hook?("\(#function) \(String(describing: key))") + } + + static func logEnvironmentEquivalencyComparisonStart(environment: Environment) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } + let token = signposter.beginInterval( + "Environment equivalency comparison", + id: environment.fingerprint.value.signpost, + "Start: \(String(describing: environment))" + ) + hook?("\(#function) \(environment.fingerprint)") + return token + } + + static func logEnvironmentEquivalencyComparisonEnd(_ token: OSSignpostIntervalState?, environment: Environment) { + guard BlueprintLogging.isEnabled, let token else { return } + signposter.endInterval("Environment equivalency comparison", token, "\(String(describing: environment))") + hook?("\(#function) \(environment.fingerprint)") + } + + static func logEnvironmentEquivalencyFingerprintEqual(environment: Environment) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("Environments trivially equal from fingerprint", id: environment.fingerprint.value.signpost) + hook?("\(#function) \(environment.fingerprint)") + } + + static func logEnvironmentEquivalencyFingerprintCacheHit(environment: Environment) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("Environment cached comparison result hit", id: environment.fingerprint.value.signpost) + hook?("\(#function) \(environment.fingerprint)") + } + + static func logEnvironmentEquivalencyFingerprintCacheMiss(environment: Environment) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("Environment cached comparison result miss", id: environment.fingerprint.value.signpost) + hook?("\(#function) \(environment.fingerprint)") + } + + static func logEnvironmentEquivalencyCompletedWithNonEquivalence( + environment: Environment, + key: some Hashable, + context: EquivalencyContext + ) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent( + "Environment equivalency completed with non-equivalent result", + id: environment.fingerprint.value.signpost, + "\(String(describing: context)): \(String(describing: key)) not equivalent" + ) + hook?("\(#function) \(String(describing: key))") + } + + static func logEnvironmentEquivalencyCompletedWithEquivalence(environment: Environment, context: EquivalencyContext) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent( + "Environment equivalency completed with equivalent result", + id: environment.fingerprint.value.signpost, + "\(String(describing: context))" + ) + hook?("\(#function) \(environment.fingerprint)") + } + + + // MARK: ValidatingCache + + static func logValidatingCacheValidationStart(key: some Hashable) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } + let token = signposter.beginInterval( + "ValidatingCache validation", + id: key.signpost, + "Start: \(String(describing: key))" + ) + hook?("\(#function) \(String(describing: key))") + return token + } + + static func logValidatingCacheValidationEnd(_ token: OSSignpostIntervalState?, key: some Hashable) { + guard BlueprintLogging.isEnabled, let token else { return } + signposter.endInterval("ValidatingCache validation", token, "\(String(describing: key))") + hook?("\(#function) \(String(describing: key))") + } + + static func logValidatingCacheFreshValueCreationStart(key: some Hashable) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } + let token = signposter.beginInterval( + "ValidatingCache fresh value creation", + id: key.signpost, + "\(String(describing: key))" + ) + hook?("\(#function) \(String(describing: key))") + return token + } + + static func logValidatingCacheFreshValueCreationEnd(_ token: OSSignpostIntervalState?, key: some Hashable) { + guard BlueprintLogging.isEnabled, let token else { return } + signposter.endInterval("ValidatingCache fresh value creation", token) + hook?("\(#function) \(String(describing: key))") + } + + static func logValidatingCacheKeyMiss(key: some Hashable) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache key miss", id: key.signpost) + hook?("\(#function) \(String(describing: key))") + } + + static func logValidatingCacheKeyHit(key: some Hashable) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache key hit", id: key.signpost) + hook?("\(#function) \(String(describing: key))") + } + + static func logValidatingCacheHitAndValidationSuccess(key: some Hashable) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache validation success", id: key.signpost) + hook?("\(#function) \(String(describing: key))") + } + + static func logValidatingCacheHitAndValidationFailure(key: some Hashable) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache validation failure", id: key.signpost) + hook?("\(#function) \(String(describing: key))") + } + +} + +extension Hashable { + + fileprivate var signpost: OSSignpostID { + OSSignpostID(UInt64(abs(hashValue))) + } + +} + +// MARK: - Utilities + +extension Logger { private static func shouldRecordMeasurePass() -> Bool { BlueprintLogging.isEnabled && BlueprintLogging.config.recordElementMeasures } + } diff --git a/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift new file mode 100644 index 000000000..85541f037 --- /dev/null +++ b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift @@ -0,0 +1,164 @@ +import Testing +@testable import BlueprintUI + +@MainActor +struct EnvironmentEquivalencyTests { + + @Test func simpleEquivalency() { + let a = Environment() + let b = Environment() + #expect(a.isEquivalent(to: b, in: .all)) + #expect(a.isEquivalent(to: b, in: .elementSizing)) + } + + @Test func simpleChange() { + var a = Environment() + a[ExampleKey.self] = 1 + let b = Environment() + #expect(!a.isEquivalent(to: b, in: .all)) + #expect(!a.isEquivalent(to: b, in: .elementSizing)) + } + + @Test func orderingWithDefaults() { + // The ordering of the comparison shouldn't matter if one value has a setting but the other doesn't. + var a = Environment() + a[ExampleKey.self] = 1 + let b = Environment() + #expect(!a.isEquivalent(to: b)) + + // Explicitly duplicated to ensure we don't hit a cached comparison. + let c = Environment() + var d = Environment() + d[ExampleKey.self] = 1 + #expect(!c.isEquivalent(to: d)) + } + + @Test func orderingWithNullability() { + // The ordering of the comparison shouldn't matter if one value has a setting but the other doesn't. + var a = Environment() + a[OptionalKey.self] = 1 + let b = Environment() + #expect(!a.isEquivalent(to: b)) + + // Explicitly duplicated to ensure we don't hit a cached comparison. + let c = Environment() + var d = Environment() + d[OptionalKey.self] = 1 + #expect(!c.isEquivalent(to: d)) + } + + @Test func modification() { + var a = Environment() + let b = a + a[ExampleKey.self] = 1 + #expect(!a.isEquivalent(to: b)) + } + + @Test func caching() { + BlueprintLogging.isEnabled = true + var hookedResult: [String] = [] + Logger.hook = { + hookedResult.append($0) + } + var a = Environment() + let b = a + a[ExampleKey.self] = 1 + hookedResult = [] + #expect(!a.isEquivalent(to: b)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) + + hookedResult = [] + #expect(!a.isEquivalent(to: b)) + // Subsequent comparison should be cached + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(a.fingerprint)")) + + hookedResult = [] + #expect(!b.isEquivalent(to: a)) + // Reversed order should still be cached + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(b.fingerprint)")) + + hookedResult = [] + let c = b + #expect(!a.isEquivalent(to: c)) + // Copying without mutation should preserve fingerprint, and be cached. + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(a.fingerprint)")) + + } + + @Test func cascading() { + BlueprintLogging.isEnabled = true + var hookedResult: [String] = [] + Logger.hook = { + hookedResult.append($0) + } + var a = Environment() + a[ExampleKey.self] = 1 + a[NonSizeAffectingKey.self] = 1 + var b = Environment() + b[ExampleKey.self] = 1 + b[NonSizeAffectingKey.self] = 2 + + hookedResult = [] + #expect(a.isEquivalent(to: b, in: .elementSizing)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) + + hookedResult = [] + #expect(!a.isEquivalent(to: b, in: .all)) + // A specific equivalency being true doesn't imply `.all` to be true, so we should see another evaluation. + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) + + var c = Environment() + c[ExampleKey.self] = 1 + var d = Environment() + d[ExampleKey.self] = 1 + + hookedResult = [] + #expect(c.isEquivalent(to: d, in: .all)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(c.fingerprint)")) + + hookedResult = [] + #expect(c.isEquivalent(to: d, in: .elementSizing)) + // `.all` equivalency implies that any more fine-grained equivalency should also be true, so we should be using a cached result. + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(c.fingerprint)")) + + // A specific equivalency being false implies `.all` to be be false, so we should be using a cached result. + var e = Environment() + e[ExampleKey.self] = 2 + let f = Environment() + + hookedResult = [] + #expect(!e.isEquivalent(to: f, in: .elementSizing)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(e.fingerprint)")) + + hookedResult = [] + #expect(!e.isEquivalent(to: f, in: .all)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(e.fingerprint)")) + + } + + func hello(closure: @autoclosure () -> Bool, message: String) { + var hookedResult: [String] = [] + Logger.hook = { + hookedResult.append($0) + } + #expect(closure()) + #expect(hookedResult.contains(message)) + } + +} + +enum ExampleKey: EnvironmentKey { + static let defaultValue = 0 +} + +enum OptionalKey: EnvironmentKey { + static let defaultValue: Int? = nil +} + +enum NonSizeAffectingKey: EnvironmentKey { + static let defaultValue = 0 + + static func isEquivalent(lhs: Int, rhs: Int, in context: EquivalencyContext) -> Bool { + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) + } +} diff --git a/BlueprintUI/Tests/ValidatingCacheTests.swift b/BlueprintUI/Tests/ValidatingCacheTests.swift new file mode 100644 index 000000000..0d9ae9f3f --- /dev/null +++ b/BlueprintUI/Tests/ValidatingCacheTests.swift @@ -0,0 +1,292 @@ +import Foundation +import Testing +@_spi(CacheStorage) @testable import BlueprintUI + +@MainActor +struct ValidatingCacheTests { + + @Test func setAndRetrieve() { + var cache = ValidatingCache() + var createCount = 0 + var validateCount = 0 + let value = cache.retrieveOrCreate(key: "Hello") { + fatalError() + } create: { + createCount += 1 + return ("World", ()) + } + #expect(value == "World") + #expect(createCount == 1) + #expect(validateCount == 0) + let secondValue = cache.retrieveOrCreate(key: "Hello") { + validateCount += 1 + return true + } create: { + createCount += 1 + return ("Hello", ()) + } + #expect(secondValue == "World") + #expect(createCount == 1) + #expect(validateCount == 1) + } + + @Test func invalidation() { + var cache = ValidatingCache() + var createCount = 0 + var validateCount = 0 + + let value = cache.retrieveOrCreate(key: "Hello") { _ in + validateCount += 1 + return true + } create: { + createCount += 1 + return ("One", ()) + } + #expect(value == "One") + #expect(createCount == 1) + #expect(validateCount == 0) + let secondValue = cache.retrieveOrCreate(key: "Hello") { _ in + validateCount += 1 + return true + } create: { + createCount += 1 + return ("Two", ()) + } + #expect(secondValue == "One") + #expect(createCount == 1) + #expect(validateCount == 1) + + let thirdValue = cache.retrieveOrCreate(key: "Hello") { + validateCount += 1 + return false + } create: { + createCount += 1 + return ("Three", ()) + } + #expect(thirdValue == "Three") + #expect(createCount == 2) + #expect(validateCount == 2) + } + +} + +@MainActor +struct EnvironmentValidatingCacheTests { + + @Test func basic() { + var cache = EnvironmentValidatingCache() + var environment = Environment() + environment[ExampleKey.self] = 1 + let one = cache.retrieveOrCreate(key: "Hello", environment: environment, context: .all) { + _ = $0[ExampleKey.self] + return "One" + } + #expect(one == "One") + + let two = cache.retrieveOrCreate(key: "Hello", environment: environment, context: .all) { + _ = $0[ExampleKey.self] + return "Two" + } + #expect(two == "One") + + let three = cache.retrieveOrCreate(key: "KeyMiss", environment: environment, context: .all) { + _ = $0[ExampleKey.self] + return "Three" + } + #expect(three == "Three") + + var differentEnvironment = environment + differentEnvironment[ExampleKey.self] = 2 + let four = cache.retrieveOrCreate(key: "Hello", environment: differentEnvironment, context: .all) { + _ = $0[ExampleKey.self] + return "Four" + } + #expect(four == "Four") + } + +} + + +@MainActor +struct EnvironmentAndValueValidatingCacheTests { + + @Test func basic() { + var cache = EnvironmentAndValueValidatingCache() + var environment = Environment() + environment[ExampleKey.self] = 1 + let one = cache.retrieveOrCreate( + key: "Hello", + environment: environment, + validationValue: "Validate", + context: .all + ) { + _ = $0[ExampleKey.self] + return "One" + } + #expect(one == "One") + + let two = cache.retrieveOrCreate( + key: "Hello", + environment: environment, + validationValue: "Validate", + context: .all + ) { + _ = $0[ExampleKey.self] + return "Two" + } + #expect(two == "One") + + let three = cache.retrieveOrCreate( + key: "KeyMiss", + environment: environment, + validationValue: "Validate", + context: .all + ) { + _ = $0[ExampleKey.self] + return "Three" + } + #expect(three == "Three") + + var differentEnvironment = environment + differentEnvironment[ExampleKey.self] = 2 + let four = cache.retrieveOrCreate( + key: "Hello", + environment: differentEnvironment, + validationValue: "Validate", + context: .all + ) { + _ = $0[ExampleKey.self] + return "Four" + } + #expect(four == "Four") + + let five = cache.retrieveOrCreate( + key: "Hello", + environment: differentEnvironment, + validationValue: "Invalid", + context: .all + ) { _ in + "Five" + } + #expect(five == "Five") + } + + @Test func basicElementsAndPaths() { + + var cache = EnvironmentAndValueValidatingCache() + let elementOne = TestCachedElement(value: "Hello") + let elementOnePath = "some/element/path" + let elementTwo = TestCachedElement(value: "Hi") + let elementTwoPath = "some/other/path" + let elementOneModified = TestCachedElement(value: "Hello World") + var environment = Environment() + + var evaluationCount = 0 + func sizeForElement(element: TestCachedElement) -> CGSize { + evaluationCount += 1 + // Fake size obviously, for demo purposes + return CGSize(width: element.value.count * 10, height: 100) + } + + // First will be a key miss, so evaluate. + let firstSize = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOne, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOne) + } + #expect(firstSize == CGSize(width: 50, height: 100)) + #expect(evaluationCount == 1) + + // Second will be a key miss also, so evaluate. + let secondSize = cache.retrieveOrCreate( + key: elementTwoPath, + environment: environment, + validationValue: elementTwo, + context: .elementSizing + ) { _ in + sizeForElement(element: elementTwo) + } + #expect(secondSize == CGSize(width: 20, height: 100)) + #expect(evaluationCount == 2) + + // Querying first size again with matching environment and validation value. Cache hit, validation pass, no evaluation. + let firstSizeAgain = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOne, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOne) + } + #expect(firstSizeAgain == CGSize(width: 50, height: 100)) + #expect(evaluationCount == 2) + + // Querying first size again with matching environment and non-matching validation value. Cache hit, validation fail, evaluation. + let firstSizeWithNewElement = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOneModified, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOneModified) + } + #expect(firstSizeWithNewElement == CGSize(width: 110, height: 100)) + #expect(evaluationCount == 3) + + // Querying first size again with matching environment and validation value. Cache hit, validation pass, no evaluation. + let firstSizeWithNewElementAgain = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOneModified, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOneModified) + } + #expect(firstSizeWithNewElementAgain == CGSize(width: 110, height: 100)) + #expect(evaluationCount == 3) + + // Querying first size again with matching environment and original validation value. Cache hit, validation fail (because we don't preserve old values for keys with different validations), evaluation. + let originalFirstSizeAgain = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOne, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOne) + } + #expect(originalFirstSizeAgain == CGSize(width: 50, height: 100)) + #expect(evaluationCount == 4) + + // Querying first size again with non-equivalent environment and matching validation value. Cache hit, validation fail (due to environment diff), evaluation. + environment[ExampleKey.self] = 1 + let firstSizeWithNewEnvironment = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOneModified, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOne) + } + #expect(firstSizeWithNewEnvironment == CGSize(width: 50, height: 100)) + #expect(evaluationCount == 5) + + + } + +} + +struct TestCachedElement: Element, Equatable, ContextuallyEquivalent { + let value: String + + var content: ElementContent { + fatalError() + } + + func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { + fatalError() + } + +} From dc4f9de1c0b532076f5b7d1a77174fe5688636cf Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 26 Nov 2025 16:59:04 -0800 Subject: [PATCH 2/7] PR feedback cleanup --- .../EnvironmentAndValueValidatingCache.swift | 55 +++++++++++ .../EnvironmentValidatingCache.swift | 33 +++++++ .../ValidatingCache.swift | 96 +------------------ .../CrossLayoutCaching/Cache/CacheKey.swift | 6 +- ...Storage.swift => HostingViewContext.swift} | 20 ++-- .../Sources/Environment/Environment.swift | 60 ++++++------ BlueprintUI/Sources/Internal/Logger.swift | 2 +- BlueprintUI/Tests/ValidatingCacheTests.swift | 2 +- 8 files changed, 138 insertions(+), 136 deletions(-) create mode 100644 BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift create mode 100644 BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentValidatingCache.swift rename BlueprintUI/Sources/CrossLayoutCaching/Cache/{ => CacheImplementations}/ValidatingCache.swift (52%) rename BlueprintUI/Sources/CrossLayoutCaching/Cache/{CacheStorage.swift => HostingViewContext.swift} (76%) diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift new file mode 100644 index 000000000..2eb2440a2 --- /dev/null +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift @@ -0,0 +1,55 @@ +import Foundation + +@_spi(HostingViewContext) extension EnvironmentAndValueValidatingCache where AdditionalValidationData: CrossLayoutCacheable { + + /// Retrieves or creates a value based on a key and a validation value, alongside environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment and validation values should be evaluated. + /// - validationValue: A value that will be compared using contextual equivalence that evaluates whether or not a given result is still valid. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + public mutating func retrieveOrCreate( + key: Key, + environment: Environment, + validationValue: AdditionalValidationData, + context: CrossLayoutCacheableContext, + create: (Environment) -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0.isCacheablyEquivalent(to: validationValue, in: context) + } create: { + (create($0), validationValue) + } + + } + +} + +@_spi(HostingViewContext) extension EnvironmentAndValueValidatingCache where AdditionalValidationData: Equatable { + + /// Retrieves or creates a value based on a key and a validation value, alongside environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment should be evaluated. + /// - validationValue: A value that will be compared using strict equality that evaluates whether or not a given result is still valid. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + @_disfavoredOverload public mutating func retrieveOrCreate( + key: Key, + environment: Environment, + validationValue: AdditionalValidationData, + context: CrossLayoutCacheableContext, + create: (Environment) -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0 == validationValue + } create: { + (create($0), validationValue) + } + } + +} + diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentValidatingCache.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentValidatingCache.swift new file mode 100644 index 000000000..8af9cdb4a --- /dev/null +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentValidatingCache.swift @@ -0,0 +1,33 @@ +import Foundation + +/// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned. +@_spi(HostingViewContext) public struct EnvironmentValidatingCache: Sendable where Key: Hashable { + + private var backing = ValidatingCache() + + public init() {} + + /// Retrieves or creates a value based on a key and environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment should be evaluated. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + mutating func retrieveOrCreate( + key: Key, + environment: Environment, + context: CrossLayoutCacheableContext, + create: (Environment) -> Value + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isCacheablyEquivalent(to: $0, in: context) + } create: { + environment.observingAccess { environment in + create(environment) + } + } + } + +} + diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/ValidatingCache.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift similarity index 52% rename from BlueprintUI/Sources/CrossLayoutCaching/Cache/ValidatingCache.swift rename to BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift index 8c00a9b7e..5b417a15e 100644 --- a/BlueprintUI/Sources/CrossLayoutCaching/Cache/ValidatingCache.swift +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift @@ -2,7 +2,7 @@ import Foundation /// Validating cache is a cache which, if it has a value for a key, runs a closure to verify that the cache value is still relevant and not state. /// This is useful for cases when you might otherwise wish to store the validation data as a key, but it does not conform to Hashable, or its hashability properties do not neccessarily affect the validity of the cached data. -@_spi(CacheStorage) public struct ValidatingCache: Sendable where Key: Hashable { +@_spi(HostingViewContext) public struct ValidatingCache: Sendable where Key: Hashable { private var storage: [Key: ValueStorage] = [:] @@ -65,41 +65,10 @@ import Foundation } -/// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned. -@_spi(CacheStorage) public struct EnvironmentValidatingCache: Sendable where Key: Hashable { - - private var backing = ValidatingCache() - - public init() {} - - /// Retrieves or creates a value based on a key and environment validation. - /// - Parameters: - /// - key: The key to look up. - /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. - /// - context: The equivalency context in which the environment should be evaluated. - /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. - /// - Returns: Either a cached or newly created value. - mutating func retrieveOrCreate( - key: Key, - environment: Environment, - context: CrossLayoutCacheableContext, - create: (Environment) -> Value - ) -> Value { - backing.retrieveOrCreate(key: key) { - environment.isCacheablyEquivalent(to: $0, in: context) - } create: { - environment.snapshottingAccess { environment in - create(environment) - } - } - } - -} - /// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned, and allows for additional data to be stored to be validated. -@_spi(CacheStorage) public struct EnvironmentAndValueValidatingCache: Sendable where Key: Hashable { +@_spi(HostingViewContext) public struct EnvironmentAndValueValidatingCache: Sendable where Key: Hashable { - private var backing = ValidatingCache() + private var backing = ValidatingCache() public init() {} @@ -122,70 +91,15 @@ import Foundation backing.retrieveOrCreate(key: key) { environment.isCacheablyEquivalent(to: $0.0, in: context) && validate($0.1) } create: { - let ((value, additional), snapshot) = environment.snapshottingAccess { environment in + let ((value, additional), accessList) = environment.observingAccess { environment in create(environment) } - return (value, (snapshot, additional)) + return (value, (accessList, additional)) } } } - -@_spi(CacheStorage) extension EnvironmentAndValueValidatingCache where AdditionalValidationData: CrossLayoutCacheable { - - /// Retrieves or creates a value based on a key and a validation value, alongside environment validation. - /// - Parameters: - /// - key: The key to look up. - /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. - /// - context: The equivalency context in which the environment and validation values should be evaluated. - /// - validationValue: A value that will be compared using contextual equivalence that evaluates whether or not a given result is still valid. - /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. - /// - Returns: Either a cached or newly created value. - public mutating func retrieveOrCreate( - key: Key, - environment: Environment, - validationValue: AdditionalValidationData, - context: CrossLayoutCacheableContext, - create: (Environment) -> (Value) - ) -> Value { - retrieveOrCreate(key: key, environment: environment, context: context) { - $0.isCacheablyEquivalent(to: validationValue, in: context) - } create: { - (create($0), validationValue) - } - - } - -} - -@_spi(CacheStorage) extension EnvironmentAndValueValidatingCache where AdditionalValidationData: Equatable { - - /// Retrieves or creates a value based on a key and a validation value, alongside environment validation. - /// - Parameters: - /// - key: The key to look up. - /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. - /// - context: The equivalency context in which the environment should be evaluated. - /// - validationValue: A value that will be compared using strict equality that evaluates whether or not a given result is still valid. - /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. - /// - Returns: Either a cached or newly created value. - @_disfavoredOverload public mutating func retrieveOrCreate( - key: Key, - environment: Environment, - validationValue: AdditionalValidationData, - context: CrossLayoutCacheableContext, - create: (Environment) -> (Value) - ) -> Value { - retrieveOrCreate(key: key, environment: environment, context: context) { - $0 == validationValue - } create: { - (create($0), validationValue) - } - } - -} - - extension Equatable { fileprivate func isEqual(_ other: any Equatable) -> Bool { diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheKey.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheKey.swift index bf29b6f46..89d735e04 100644 --- a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheKey.swift +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheKey.swift @@ -1,6 +1,6 @@ import Foundation -/// Types conforming to this protocol can be used as keys in `CacheStorage`. +/// Types conforming to this protocol can be used as keys in `HostingViewContext`. /// /// Using a type as the key allows us to strongly type each value, with the /// key's `CrossLayoutCacheKey.Value` associated value. @@ -13,9 +13,9 @@ import Foundation /// static let emptyValue: [WidgetID: Int] = [:] /// } /// -/// You can write a small extension on `CacheStorage` to make it easier to use your key. +/// You can write a small extension on `HostingViewContext` to make it easier to use your key. /// -/// extension CacheStorage { +/// extension HostingViewContext { /// var widgetCounts: [WidgetID: Int] { /// get { self[WidgetCountsKey.self] } /// set { self[WidgetCountsKey.self] = newValue } diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheStorage.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/HostingViewContext.swift similarity index 76% rename from BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheStorage.swift rename to BlueprintUI/Sources/CrossLayoutCaching/Cache/HostingViewContext.swift index 6ded86fb8..794dfdcbf 100644 --- a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheStorage.swift +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/HostingViewContext.swift @@ -4,7 +4,7 @@ import UIKit /// Environment-associated storage used to cache types used across layout passes (eg, size calculations). /// The storage itself is type-agnostic, requiring only that its keys and values conform to the `CrossLayoutCacheKey` protocol /// Caches are responsible for managing their own lifetimes and eviction strategies. -@_spi(CacheStorage) public final class CacheStorage: Sendable, CustomDebugStringConvertible { +@_spi(HostingViewContext) public final class HostingViewContext: Sendable, CustomDebugStringConvertible { // Optional name to distinguish between instances for debugging purposes. public var name: String? = nil @@ -31,9 +31,9 @@ import UIKit public var debugDescription: String { let debugName = if let name { - "CacheStorage (\(name))" + "HostingViewContext (\(name))" } else { - "CacheStorage" + "HostingViewContext" } return "\(debugName): \(storage.count) entries" } @@ -42,14 +42,14 @@ import UIKit extension Environment { - struct CacheStorageEnvironmentKey: InternalEnvironmentKey { - static var defaultValue = CacheStorage() + struct HostingViewContextKey: InternalEnvironmentKey { + static var defaultValue = HostingViewContext() } - @_spi(CacheStorage) public var cacheStorage: CacheStorage { - get { self[CacheStorageEnvironmentKey.self] } - set { self[CacheStorageEnvironmentKey.self] = newValue } + @_spi(HostingViewContext) public var hostingViewContext: HostingViewContext { + get { self[HostingViewContextKey.self] } + set { self[HostingViewContextKey.self] = newValue } } } @@ -58,7 +58,7 @@ extension Environment { /// Two fingerprinted objects may be quickly compared for equality by comparing their fingerprints. /// This is roughly analagous to a hash, although with inverted properties: Two objects with the same fingerprint can be trivially considered equal, but two otherwise equal objects may have different fingerprint. /// - Note: This type is deliberately NOT equatable – this is to prevent accidental inclusion of it when its containing type is equatable. -struct ComparableFingerprint: CrossLayoutCacheable, CustomStringConvertible { +struct CacheComparisonFingerprint: CrossLayoutCacheable, CustomStringConvertible { typealias Value = UUID @@ -73,7 +73,7 @@ struct ComparableFingerprint: CrossLayoutCacheable, CustomStringConvertible { } /// - Note: This is a duplicate message but: this type is deliberately NOT equatable – this is to prevent accidental inclusion of it when its containing type is equatable. Use this instead. - func isCacheablyEquivalent(to other: ComparableFingerprint?, in context: CrossLayoutCacheableContext) -> Bool { + func isCacheablyEquivalent(to other: CacheComparisonFingerprint?, in context: CrossLayoutCacheableContext) -> Bool { value == other?.value } diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index 51925aaf7..06d5abec7 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -41,10 +41,10 @@ public struct Environment { public static let empty = Environment() // Fingerprint used for referencing previously compared environments. - var fingerprint = ComparableFingerprint() + var fingerprint = CacheComparisonFingerprint() private var values: [Keybox: Any] = [:] - private var snapshotting: SnapshottingEnvironment? + private var observingAccess: ObservingAccessListEnvironment? // Internal values are hidden from consumers and do not participate in cross-layout cacheability checks. private var internalValues: [ObjectIdentifier: Any] = [:] @@ -64,8 +64,8 @@ public struct Environment { private subscript(keybox: Keybox) -> Any { let value = values[keybox, default: keybox.type.defaultValue] - if let snapshotting { - snapshotting.value.values[keybox] = value + if let observingAccess { + observingAccess.value.values[keybox] = value } return value } @@ -94,27 +94,27 @@ public struct Environment { return merged } - func snapshottingAccess(_ closure: (Environment) -> T) -> (T, EnvironmentSnapshot) { + func observingAccess(_ closure: (Environment) -> T) -> (T, EnvironmentAccessList) { var watching = self - let snapshotting = SnapshottingEnvironment() - watching.snapshotting = snapshotting + let observingAccess = ObservingAccessListEnvironment() + watching.observingAccess = observingAccess let result = closure(watching) - return (result, snapshotting.value) + return (result, observingAccess.value) } } -/// An environment snapshot is immutable copy of the comparable elements of an Environment struct that were accessed during the cached value's creaton.. -struct EnvironmentSnapshot { +/// An environment access list is frozen-in-time copy of the comparable elements of an Environment struct that were accessed during the cached value's creaton. +struct EnvironmentAccessList { // Fingerprint used for referencing previously compared environments. - var fingerprint: ComparableFingerprint + var fingerprint: CacheComparisonFingerprint var values: [Environment.Keybox: Any] } -private final class SnapshottingEnvironment { - var value = EnvironmentSnapshot(fingerprint: .init(), values: [:]) +private final class ObservingAccessListEnvironment { + var value = EnvironmentAccessList(fingerprint: .init(), values: [:]) } extension Environment: CrossLayoutCacheable { @@ -125,7 +125,7 @@ extension Environment: CrossLayoutCacheable { Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) return true } - if let evaluated = cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] { + if let evaluated = hostingViewContext.environmentComparisonCache[fingerprint, other.fingerprint, context] { Logger.logEnvironmentEquivalencyFingerprintCacheHit(environment: self) return evaluated } @@ -134,7 +134,7 @@ extension Environment: CrossLayoutCacheable { let keys = Set(values.keys).union(other.values.keys) for key in keys { guard key.isCacheablyEquivalent(self[key], other[key], context) else { - cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] = false + hostingViewContext.environmentComparisonCache[fingerprint, other.fingerprint, context] = false Logger.logEnvironmentEquivalencyCompletedWithNonEquivalence( environment: self, key: key, @@ -146,27 +146,27 @@ extension Environment: CrossLayoutCacheable { } Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) Logger.logEnvironmentEquivalencyCompletedWithEquivalence(environment: self, context: context) - cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] = true + hostingViewContext.environmentComparisonCache[fingerprint, other.fingerprint, context] = true return true } - func isCacheablyEquivalent(to snapshot: EnvironmentSnapshot?, in context: CrossLayoutCacheableContext) -> Bool { - guard let snapshot else { return false } + func isCacheablyEquivalent(to accessList: EnvironmentAccessList?, in context: CrossLayoutCacheableContext) -> Bool { + guard let accessList else { return false } // We don't even need to thaw the environment if the fingerprints match. - if snapshot.fingerprint.isCacheablyEquivalent(to: fingerprint) { + if accessList.fingerprint.isCacheablyEquivalent(to: fingerprint) { Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) return true } - let scope = Set(snapshot.values.keys.map(\.objectIdentifier)) - if let evaluated = cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] { + let scope = Set(accessList.values.keys.map(\.objectIdentifier)) + if let evaluated = hostingViewContext.environmentComparisonCache[fingerprint, accessList.fingerprint, context, scope] { Logger.logEnvironmentEquivalencyFingerprintCacheHit(environment: self) return evaluated } Logger.logEnvironmentEquivalencyFingerprintCacheMiss(environment: self) let token = Logger.logEnvironmentEquivalencyComparisonStart(environment: self) - for (key, value) in snapshot.values { + for (key, value) in accessList.values { guard key.isCacheablyEquivalent(self[key], value, context) else { - cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] = false + hostingViewContext.environmentComparisonCache[fingerprint, accessList.fingerprint, context, scope] = false Logger.logEnvironmentEquivalencyCompletedWithNonEquivalence( environment: self, key: key, @@ -178,7 +178,7 @@ extension Environment: CrossLayoutCacheable { } Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) Logger.logEnvironmentEquivalencyCompletedWithEquivalence(environment: self, context: context) - cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] = true + hostingViewContext.environmentComparisonCache[fingerprint, accessList.fingerprint, context, scope] = true return true } @@ -186,16 +186,16 @@ extension Environment: CrossLayoutCacheable { } -extension CacheStorage { +extension HostingViewContext { fileprivate struct EnvironmentFingerprintCache { struct Key: Hashable { - let lhs: ComparableFingerprint.Value - let rhs: ComparableFingerprint.Value + let lhs: CacheComparisonFingerprint.Value + let rhs: CacheComparisonFingerprint.Value let scope: Set? - init(_ lhs: ComparableFingerprint.Value, _ rhs: ComparableFingerprint.Value, scope: Set?) { + init(_ lhs: CacheComparisonFingerprint.Value, _ rhs: CacheComparisonFingerprint.Value, scope: Set?) { // Sort lhs/rhs so we don't have diff results based on caller. self.lhs = min(lhs, rhs) self.rhs = max(lhs, rhs) @@ -207,8 +207,8 @@ extension CacheStorage { var storage: [Key: [CrossLayoutCacheableContext: Bool]] = [:] public subscript( - lhs: ComparableFingerprint, - rhs: ComparableFingerprint, + lhs: CacheComparisonFingerprint, + rhs: CacheComparisonFingerprint, context: CrossLayoutCacheableContext, scope: Set? = nil ) -> Bool? { diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index 05043beee..382ff1aaf 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -194,7 +194,7 @@ extension Logger { } -// MARK: - CacheStorage +// MARK: - HostingViewContext extension Logger { diff --git a/BlueprintUI/Tests/ValidatingCacheTests.swift b/BlueprintUI/Tests/ValidatingCacheTests.swift index 0d9ae9f3f..8327f7663 100644 --- a/BlueprintUI/Tests/ValidatingCacheTests.swift +++ b/BlueprintUI/Tests/ValidatingCacheTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@_spi(CacheStorage) @testable import BlueprintUI +@_spi(HostingViewContext) @testable import BlueprintUI @MainActor struct ValidatingCacheTests { From f615bb377a254248c6ae7de751a011adff44a127 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 26 Nov 2025 16:59:45 -0800 Subject: [PATCH 3/7] PR feedback cleanup --- BlueprintUI/Sources/Internal/Logger.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index 382ff1aaf..349076315 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -3,7 +3,9 @@ import os.log /// Namespace for logging helpers enum Logger { - fileprivate static let signposter = OSSignposter(logHandle: .active) + fileprivate static var signposter: OSSignposter { + OSSignposter(logHandle: .active) + } static var hook: ((String) -> Void)? } From 50fee4edf3d9052b23d96b08e17083fe7695127c Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 26 Nov 2025 18:23:32 -0800 Subject: [PATCH 4/7] Fix tests --- .../Tests/EnvironmentEquivalencyTests.swift | 40 +++++++++---------- BlueprintUI/Tests/UIViewElementTests.swift | 2 +- BlueprintUI/Tests/ValidatingCacheTests.swift | 2 +- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift index 55613ee9f..0f9ff6eb5 100644 --- a/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift +++ b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift @@ -7,16 +7,16 @@ struct EnvironmentEquivalencyTests { @Test func simpleEquivalency() { let a = Environment() let b = Environment() - #expect(a.isEquivalent(to: b, in: .all)) - #expect(a.isEquivalent(to: b, in: .elementSizing)) + #expect(a.isCacheablyEquivalent(to: b, in: .all)) + #expect(a.isCacheablyEquivalent(to: b, in: .elementSizing)) } @Test func simpleChange() { var a = Environment() a[ExampleKey.self] = 1 let b = Environment() - #expect(!a.isEquivalent(to: b, in: .all)) - #expect(!a.isEquivalent(to: b, in: .elementSizing)) + #expect(!a.isCacheablyEquivalent(to: b, in: .all)) + #expect(!a.isCacheablyEquivalent(to: b, in: .elementSizing)) } @Test func orderingWithDefaults() { @@ -24,13 +24,13 @@ struct EnvironmentEquivalencyTests { var a = Environment() a[ExampleKey.self] = 1 let b = Environment() - #expect(!a.isEquivalent(to: b)) + #expect(!a.isCacheablyEquivalent(to: b)) // Explicitly duplicated to ensure we don't hit a cached comparison. let c = Environment() var d = Environment() d[ExampleKey.self] = 1 - #expect(!c.isEquivalent(to: d)) + #expect(!c.isCacheablyEquivalent(to: d)) } @Test func orderingWithNullability() { @@ -38,20 +38,20 @@ struct EnvironmentEquivalencyTests { var a = Environment() a[OptionalKey.self] = 1 let b = Environment() - #expect(!a.isEquivalent(to: b)) + #expect(!a.isCacheablyEquivalent(to: b)) // Explicitly duplicated to ensure we don't hit a cached comparison. let c = Environment() var d = Environment() d[OptionalKey.self] = 1 - #expect(!c.isEquivalent(to: d)) + #expect(!c.isCacheablyEquivalent(to: d)) } @Test func modification() { var a = Environment() let b = a a[ExampleKey.self] = 1 - #expect(!a.isEquivalent(to: b)) + #expect(!a.isCacheablyEquivalent(to: b)) } @Test func caching() { @@ -64,22 +64,22 @@ struct EnvironmentEquivalencyTests { let b = a a[ExampleKey.self] = 1 hookedResult = [] - #expect(!a.isEquivalent(to: b)) + #expect(!a.isCacheablyEquivalent(to: b)) #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) hookedResult = [] - #expect(!a.isEquivalent(to: b)) + #expect(!a.isCacheablyEquivalent(to: b)) // Subsequent comparison should be cached #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(a.fingerprint)")) hookedResult = [] - #expect(!b.isEquivalent(to: a)) + #expect(!b.isCacheablyEquivalent(to: a)) // Reversed order should still be cached #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(b.fingerprint)")) hookedResult = [] let c = b - #expect(!a.isEquivalent(to: c)) + #expect(!a.isCacheablyEquivalent(to: c)) // Copying without mutation should preserve fingerprint, and be cached. #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(a.fingerprint)")) @@ -99,11 +99,11 @@ struct EnvironmentEquivalencyTests { b[NonSizeAffectingKey.self] = 2 hookedResult = [] - #expect(a.isEquivalent(to: b, in: .elementSizing)) + #expect(a.isCacheablyEquivalent(to: b, in: .elementSizing)) #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) hookedResult = [] - #expect(!a.isEquivalent(to: b, in: .all)) + #expect(!a.isCacheablyEquivalent(to: b, in: .all)) // A specific equivalency being true doesn't imply `.all` to be true, so we should see another evaluation. #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) @@ -113,11 +113,11 @@ struct EnvironmentEquivalencyTests { d[ExampleKey.self] = 1 hookedResult = [] - #expect(c.isEquivalent(to: d, in: .all)) + #expect(c.isCacheablyEquivalent(to: d, in: .all)) #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(c.fingerprint)")) hookedResult = [] - #expect(c.isEquivalent(to: d, in: .elementSizing)) + #expect(c.isCacheablyEquivalent(to: d, in: .elementSizing)) // `.all` equivalency implies that any more fine-grained equivalency should also be true, so we should be using a cached result. #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(c.fingerprint)")) @@ -127,11 +127,11 @@ struct EnvironmentEquivalencyTests { let f = Environment() hookedResult = [] - #expect(!e.isEquivalent(to: f, in: .elementSizing)) + #expect(!e.isCacheablyEquivalent(to: f, in: .elementSizing)) #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(e.fingerprint)")) hookedResult = [] - #expect(!e.isEquivalent(to: f, in: .all)) + #expect(!e.isCacheablyEquivalent(to: f, in: .all)) #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(e.fingerprint)")) } @@ -158,7 +158,7 @@ enum OptionalKey: EnvironmentKey { enum NonSizeAffectingKey: EnvironmentKey { static let defaultValue = 0 - static func isEquivalent(lhs: Int, rhs: Int, in context: CrossLayoutCacheableContext) -> Bool { + static func isCacheablyEquivalent(lhs: Int, rhs: Int, in context: CrossLayoutCacheableContext) -> Bool { alwaysEquivalentIn([.elementSizing], evaluatingContext: context) } } diff --git a/BlueprintUI/Tests/UIViewElementTests.swift b/BlueprintUI/Tests/UIViewElementTests.swift index 5b34b6272..0accf00cc 100644 --- a/BlueprintUI/Tests/UIViewElementTests.swift +++ b/BlueprintUI/Tests/UIViewElementTests.swift @@ -109,7 +109,7 @@ class UIViewElementTests: XCTestCase { func test_environment() { enum TestKey: EnvironmentKey { static let defaultValue: Void? = nil - static func isEquivalent(lhs: Void?, rhs: Void?, in context: CrossLayoutCacheableContext) -> Bool { + static func isCacheablyEquivalent(lhs: Void?, rhs: Void?, in context: CrossLayoutCacheableContext) -> Bool { lhs == nil && rhs == nil || rhs != nil && lhs != nil } } diff --git a/BlueprintUI/Tests/ValidatingCacheTests.swift b/BlueprintUI/Tests/ValidatingCacheTests.swift index 8327f7663..70fde0bd4 100644 --- a/BlueprintUI/Tests/ValidatingCacheTests.swift +++ b/BlueprintUI/Tests/ValidatingCacheTests.swift @@ -278,7 +278,7 @@ struct EnvironmentAndValueValidatingCacheTests { } -struct TestCachedElement: Element, Equatable, ContextuallyEquivalent { +struct TestCachedElement: Element, Equatable, CrossLayoutCacheable { let value: String var content: ElementContent { From 72a7cecf3570572f3e227b12eb1fcc30ebee7846 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Tue, 2 Dec 2025 10:34:39 -0800 Subject: [PATCH 5/7] Move cache --- .../EnvironmentAndValueValidatingCache.swift | 35 +++++++++++++++++++ .../ValidatingCache.swift | 35 ------------------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift index 2eb2440a2..66d3d4ee0 100644 --- a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift @@ -1,5 +1,40 @@ import Foundation +/// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned, and allows for additional data to be stored to be validated. +@_spi(HostingViewContext) public struct EnvironmentAndValueValidatingCache: Sendable where Key: Hashable { + + private var backing = ValidatingCache() + + public init() {} + + /// Retrieves or creates a value based on a key and a validation function, alongside environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment should be evaluated. + /// - validate: A function that evaluates whether or not a given result is still valid. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + /// - Note: Generally, prefer the `validationValue` versions of this method if the validation value conforms to ContextuallyEquivalent or Equatable. + mutating func retrieveOrCreate( + key: Key, + environment: Environment, + context: CrossLayoutCacheableContext, + validate: (AdditionalValidationData) -> Bool, + create: (Environment) -> (Value, AdditionalValidationData) + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isCacheablyEquivalent(to: $0.0, in: context) && validate($0.1) + } create: { + let ((value, additional), accessList) = environment.observingAccess { environment in + create(environment) + } + return (value, (accessList, additional)) + } + } + +} + @_spi(HostingViewContext) extension EnvironmentAndValueValidatingCache where AdditionalValidationData: CrossLayoutCacheable { /// Retrieves or creates a value based on a key and a validation value, alongside environment validation. diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift index 5b417a15e..69f9fd520 100644 --- a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift @@ -65,41 +65,6 @@ import Foundation } -/// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned, and allows for additional data to be stored to be validated. -@_spi(HostingViewContext) public struct EnvironmentAndValueValidatingCache: Sendable where Key: Hashable { - - private var backing = ValidatingCache() - - public init() {} - - /// Retrieves or creates a value based on a key and a validation function, alongside environment validation. - /// - Parameters: - /// - key: The key to look up. - /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. - /// - context: The equivalency context in which the environment should be evaluated. - /// - validate: A function that evaluates whether or not a given result is still valid. - /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. - /// - Returns: Either a cached or newly created value. - /// - Note: Generally, prefer the `validationValue` versions of this method if the validation value conforms to ContextuallyEquivalent or Equatable. - mutating func retrieveOrCreate( - key: Key, - environment: Environment, - context: CrossLayoutCacheableContext, - validate: (AdditionalValidationData) -> Bool, - create: (Environment) -> (Value, AdditionalValidationData) - ) -> Value { - backing.retrieveOrCreate(key: key) { - environment.isCacheablyEquivalent(to: $0.0, in: context) && validate($0.1) - } create: { - let ((value, additional), accessList) = environment.observingAccess { environment in - create(environment) - } - return (value, (accessList, additional)) - } - } - -} - extension Equatable { fileprivate func isEqual(_ other: any Equatable) -> Bool { From 1ba5ca4eae0fb0fdf797e65e6557d0fbe6c8e8ae Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Tue, 2 Dec 2025 15:07:22 -0800 Subject: [PATCH 6/7] Ref semantics for caches --- .../EnvironmentAndValueValidatingCache.swift | 8 ++++---- .../CacheImplementations/EnvironmentValidatingCache.swift | 4 ++-- .../Cache/CacheImplementations/ValidatingCache.swift | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift index 66d3d4ee0..d0e1f92b2 100644 --- a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift @@ -1,7 +1,7 @@ import Foundation /// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned, and allows for additional data to be stored to be validated. -@_spi(HostingViewContext) public struct EnvironmentAndValueValidatingCache: Sendable where Key: Hashable { +@_spi(HostingViewContext) public final class EnvironmentAndValueValidatingCache: Sendable where Key: Hashable & Sendable { private var backing = ValidatingCache() @@ -16,7 +16,7 @@ import Foundation /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. /// - Returns: Either a cached or newly created value. /// - Note: Generally, prefer the `validationValue` versions of this method if the validation value conforms to ContextuallyEquivalent or Equatable. - mutating func retrieveOrCreate( + func retrieveOrCreate( key: Key, environment: Environment, context: CrossLayoutCacheableContext, @@ -45,7 +45,7 @@ import Foundation /// - validationValue: A value that will be compared using contextual equivalence that evaluates whether or not a given result is still valid. /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. /// - Returns: Either a cached or newly created value. - public mutating func retrieveOrCreate( + public func retrieveOrCreate( key: Key, environment: Environment, validationValue: AdditionalValidationData, @@ -72,7 +72,7 @@ import Foundation /// - validationValue: A value that will be compared using strict equality that evaluates whether or not a given result is still valid. /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. /// - Returns: Either a cached or newly created value. - @_disfavoredOverload public mutating func retrieveOrCreate( + @_disfavoredOverload public func retrieveOrCreate( key: Key, environment: Environment, validationValue: AdditionalValidationData, diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentValidatingCache.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentValidatingCache.swift index 8af9cdb4a..880f87217 100644 --- a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentValidatingCache.swift +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentValidatingCache.swift @@ -1,7 +1,7 @@ import Foundation /// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned. -@_spi(HostingViewContext) public struct EnvironmentValidatingCache: Sendable where Key: Hashable { +@_spi(HostingViewContext) public final class EnvironmentValidatingCache: Sendable where Key: Hashable & Sendable { private var backing = ValidatingCache() @@ -14,7 +14,7 @@ import Foundation /// - context: The equivalency context in which the environment should be evaluated. /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. /// - Returns: Either a cached or newly created value. - mutating func retrieveOrCreate( + func retrieveOrCreate( key: Key, environment: Environment, context: CrossLayoutCacheableContext, diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift index 69f9fd520..03f2efe4b 100644 --- a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift @@ -2,7 +2,7 @@ import Foundation /// Validating cache is a cache which, if it has a value for a key, runs a closure to verify that the cache value is still relevant and not state. /// This is useful for cases when you might otherwise wish to store the validation data as a key, but it does not conform to Hashable, or its hashability properties do not neccessarily affect the validity of the cached data. -@_spi(HostingViewContext) public struct ValidatingCache: Sendable where Key: Hashable { +@_spi(HostingViewContext) public final class ValidatingCache: Sendable where Key: Hashable & Sendable { private var storage: [Key: ValueStorage] = [:] @@ -24,7 +24,7 @@ import Foundation /// - validate: A function that evaluates whether or not a given result is still valid. /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. /// - Returns: Either a cached or newly created value. - public mutating func retrieveOrCreate( + public func retrieveOrCreate( key: Key, validate: (ValidationData) -> Bool, create: () -> (Value, ValidationData) @@ -59,7 +59,7 @@ import Foundation return fresh } - public mutating func removeValue(forKey key: Key) -> Value? { + public func removeValue(forKey key: Key) -> Value? { storage.removeValue(forKey: key)?.value } From 9df57ad9f258dd2617df36d7771c2fdda5510963 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Tue, 2 Dec 2025 15:28:42 -0800 Subject: [PATCH 7/7] Ref semantics for caches --- BlueprintUI/Sources/Environment/Environment.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index 06d5abec7..b1e6d8060 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -188,7 +188,7 @@ extension Environment: CrossLayoutCacheable { extension HostingViewContext { - fileprivate struct EnvironmentFingerprintCache { + fileprivate final class EnvironmentFingerprintCache { struct Key: Hashable { let lhs: CacheComparisonFingerprint.Value