From 3abebaf01433469541aee98852cb7ef9dcbf77af Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 15:50:31 -0700 Subject: [PATCH 01/34] CacheStorage --- .../Sources/BlueprintView/BlueprintView.swift | 7 +++ .../Sources/Environment/Cache/CacheKey.swift | 27 ++++++++++++ .../Environment/Cache/CacheStorage.swift | 43 +++++++++++++++++++ .../Cache/EnvironmentEntangledCache.swift | 43 +++++++++++++++++++ .../Sources/Environment/Environment.swift | 31 +++++++++++++ .../Internal/InternalEnvironmentKey.swift | 13 ++++++ 6 files changed, 164 insertions(+) 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/EnvironmentEntangledCache.swift create mode 100644 BlueprintUI/Sources/Internal/InternalEnvironmentKey.swift diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index c934db457..39e65c11b 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -40,6 +40,8 @@ public final class BlueprintView: UIView { private var sizesThatFit: [SizeConstraint: CGSize] = [:] + private var cacheStorage = Environment.CacheStorageEnvironmentKey.defaultValue + /// A base environment used when laying out and rendering the element tree. /// /// Some keys will be overridden with the traits from the view itself. Eg, `windowSize`, `safeAreaInsets`, etc. @@ -148,6 +150,7 @@ public final class BlueprintView: UIView { self.element = element self.environment = environment + self.environment.cacheStorage = cacheStorage rootController = NativeViewController( node: NativeViewNode( @@ -542,9 +545,13 @@ public final class BlueprintView: UIView { environment.layoutMode = layoutMode } + environment.cacheStorage = cacheStorage + return environment } + + private func handleAppeared() { rootController.traverse { node in node.onAppear?() 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..27a0cbb39 --- /dev/null +++ b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift @@ -0,0 +1,43 @@ +import Foundation + +/// 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 + private var storage: [ObjectIdentifier: Any] = [:] + + 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 { + if let name { + "CacheStorage (\(name))" + } else { + "CacheStorage" + } + } + +} + + +extension Environment { + + struct CacheStorageEnvironmentKey: InternalEnvironmentKey { + static var defaultValue = CacheStorage() + } + + @_spi(CacheStorage) public var cacheStorage: CacheStorage { + get { self[internal: CacheStorageEnvironmentKey.self] } + set { self[internal: CacheStorageEnvironmentKey.self] = newValue } + } + +} diff --git a/BlueprintUI/Sources/Environment/Cache/EnvironmentEntangledCache.swift b/BlueprintUI/Sources/Environment/Cache/EnvironmentEntangledCache.swift new file mode 100644 index 000000000..3e2da853c --- /dev/null +++ b/BlueprintUI/Sources/Environment/Cache/EnvironmentEntangledCache.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Simple dictionary-like key value cache with enforcement around environment consistency. +@_spi(CacheStorage) public struct EnvironmentEntangledCache: Sendable { + + private var storage: [Key: (Environment, Value)] = [:] + + public subscript(uncheckedKey key: Key) -> Value? { + storage[key]?.1 + } + + /// Retrieves a value if one exists in the cache for a given environment and equivalency context. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The environment being evaluated. + /// - context: The context in which the environment is being evaluated in. + /// - create: A closure to create a fresh value if no suitable cached value is available. + /// - Returns: A cached or freshly created value. + /// - Note: If no value exists for the key, or a value exists for the key but the environment does not meet the require equivalency, any existing value will be evicted from the cache and a fresh value will be created and stored. + public mutating func retrieveOrCreate( + key: Key, + environment: Environment, + context: EquivalencyContext, + create: () -> Value + ) -> Value { + if let existing = storage[key] { + if existing.0.isEquivalent(to: environment, in: context) { + return existing.1 + } else { + storage.removeValue(forKey: key) + } + } + let fresh = create() + storage[key] = (environment, fresh) + return fresh + } + + public mutating func removeValue(forKey key: Key) -> Value? { + storage.removeValue(forKey: key)?.1 + } + +} + diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index 2fb33df8a..6fae5ae06 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -46,6 +46,8 @@ public struct Environment { } } + private var internalValues: [ObjectIdentifier: Any] = [:] + // Fingerprint used for referencing previously compared environments. fileprivate var fingerprint: UUID = UUID() @@ -63,6 +65,15 @@ public struct Environment { values[keybox, default: keybox.type.defaultValue] } + public subscript(internal key: Key.Type) -> Key.Value where Key: EnvironmentKey { + get { + internalValues[ObjectIdentifier(key)] as! Key.Value + } + set { + internalValues[ObjectIdentifier(key)] = newValue + } + } + /// If the `Environment` contains any values. var isEmpty: Bool { values.isEmpty @@ -83,17 +94,37 @@ extension Environment: ContextuallyEquivalent { public func isEquivalent(to other: Environment?, in context: EquivalencyContext) -> Bool { guard let other else { return false } if fingerprint == other.fingerprint { return true } + if let evaluated = cacheStorage.environmentComparisonCache[other.fingerprint] ?? other.cacheStorage.environmentComparisonCache[fingerprint], let result = evaluated[context] { + return result + } let keys = Set(values.keys).union(other.values.keys) for key in keys { guard key.isEquivalent(self[key], other[key], context) else { + cacheStorage.environmentComparisonCache[other.fingerprint, default: [:]][context] = false return false } } + cacheStorage.environmentComparisonCache[other.fingerprint, default: [:]][context] = true return true } } + +extension CacheStorage { + + /// A cache of previously compared environments and their results. + private struct EnvironmentComparisonCacheKey: CacheKey { + static var emptyValue: [UUID: [EquivalencyContext: Bool]] = [:] + } + + fileprivate var environmentComparisonCache: [UUID: [EquivalencyContext: Bool]] { + get { self[EnvironmentComparisonCacheKey.self] } + set { self[EnvironmentComparisonCacheKey.self] = newValue } + } + +} + extension Environment { /// Lightweight key type eraser. 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 + } + +} From 9b87e2a5b20a1e447ea646cf2c92defd6b4f19d3 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 16:09:57 -0700 Subject: [PATCH 02/34] Add MeasurableStorage caching. --- .../Sources/Element/ElementContent.swift | 4 +- .../Sources/Element/MeasurableStorage.swift | 40 ++++++++- BlueprintUI/Sources/Layout/LayoutMode.swift | 23 +++-- .../Sources/Layout/LayoutOptions.swift | 15 +++- .../Sources/AttributedLabel.swift | 88 +------------------ 5 files changed, 72 insertions(+), 98 deletions(-) diff --git a/BlueprintUI/Sources/Element/ElementContent.swift b/BlueprintUI/Sources/Element/ElementContent.swift index aa3ac6bfd..7563a6cc3 100644 --- a/BlueprintUI/Sources/Element/ElementContent.swift +++ b/BlueprintUI/Sources/Element/ElementContent.swift @@ -242,11 +242,13 @@ extension ElementContent { /// Initializes a new `ElementContent` with no children that delegates to the provided measure function. /// + /// - parameter cacheKey: If present, a key with which the measureFunction result will be cached. The key will be hashed and discarded to avoid memory bloat. /// - parameter measureFunction: How to measure the `ElementContent` in the given `SizeConstraint` and `Environment`. public init( + cacheKey: AnyHashable? = nil, measureFunction: @escaping (SizeConstraint, Environment) -> CGSize ) { - storage = MeasurableStorage(measurer: measureFunction) + storage = MeasurableStorage(cacheKey: cacheKey, measurer: measureFunction) } /// Initializes a new `ElementContent` with no children that uses the provided intrinsic size for measuring. diff --git a/BlueprintUI/Sources/Element/MeasurableStorage.swift b/BlueprintUI/Sources/Element/MeasurableStorage.swift index 1fd389230..5eb48a5df 100644 --- a/BlueprintUI/Sources/Element/MeasurableStorage.swift +++ b/BlueprintUI/Sources/Element/MeasurableStorage.swift @@ -7,6 +7,7 @@ struct MeasurableStorage: ContentStorage { let childCount = 0 + let cacheKey: AnyHashable? let measurer: (SizeConstraint, Environment) -> CGSize } @@ -17,7 +18,17 @@ extension MeasurableStorage: CaffeinatedContentStorage { environment: Environment, node: LayoutTreeNode ) -> CGSize { - measurer(proposal, environment) + guard environment.layoutMode.options.measureableStorageCache, let cacheKey else { + return measurer(proposal, environment) + } + let key = MeasurableSizeKey(model: cacheKey, max: proposal.maximum) + return environment.cacheStorage.measurableStorageCache.retrieveOrCreate( + key: key, + environment: environment, + context: .internalElementLayout + ) { + measurer(proposal, environment) + } } func performCaffeinatedLayout( @@ -28,3 +39,30 @@ extension MeasurableStorage: CaffeinatedContentStorage { [] } } + +extension MeasurableStorage { + + fileprivate struct MeasurableSizeKey: Hashable { + let hashValue: Int + init(model: AnyHashable, max: CGSize) { + var hasher = Hasher() + model.hash(into: &hasher) + max.hash(into: &hasher) + hashValue = hasher.finalize() + } + } + +} + +extension CacheStorage { + + private struct MeasurableStorageCacheKey: CacheKey { + static var emptyValue = EnvironmentEntangledCache() + } + + fileprivate var measurableStorageCache: EnvironmentEntangledCache { + get { self[MeasurableStorageCacheKey.self] } + set { self[MeasurableStorageCacheKey.self] = newValue } + } + +} diff --git a/BlueprintUI/Sources/Layout/LayoutMode.swift b/BlueprintUI/Sources/Layout/LayoutMode.swift index da4f7f2d5..5be6c16c9 100644 --- a/BlueprintUI/Sources/Layout/LayoutMode.swift +++ b/BlueprintUI/Sources/Layout/LayoutMode.swift @@ -11,7 +11,7 @@ import Foundation /// Changing the default will cause all instances of ``BlueprintView`` to be invalidated, and re- /// render their contents. /// -public struct LayoutMode: Equatable { +public struct LayoutMode: Hashable { public static var `default`: Self = .caffeinated { didSet { guard oldValue != .default else { return } @@ -41,15 +41,20 @@ public struct LayoutMode: Equatable { extension LayoutMode: CustomStringConvertible { public var description: String { - switch (options.hintRangeBoundaries, options.searchUnconstrainedKeys) { - case (true, true): - return "Caffeinated (hint+search)" - case (true, false): - return "Caffeinated (hint)" - case (false, true): - return "Caffeinated (search)" - case (false, false): + var optionsDescription: [String] = [] + if options.hintRangeBoundaries { + optionsDescription.append("hint") + } + if options.searchUnconstrainedKeys { + optionsDescription.append("search") + } + if options.measureableStorageCache { + optionsDescription.append("measureableStorageCache") + } + if optionsDescription.isEmpty { return "Caffeinated" + } else { + return "Caffeinated \(optionsDescription.joined(separator: "+"))" } } } diff --git a/BlueprintUI/Sources/Layout/LayoutOptions.swift b/BlueprintUI/Sources/Layout/LayoutOptions.swift index eb778295d..2b066ccd1 100644 --- a/BlueprintUI/Sources/Layout/LayoutOptions.swift +++ b/BlueprintUI/Sources/Layout/LayoutOptions.swift @@ -4,12 +4,13 @@ import Foundation /// /// Generally these are only useful for experimenting with the performance profile of different /// element compositions, and you should stick with ``default``. -public struct LayoutOptions: Equatable { +public struct LayoutOptions: Hashable { /// The default configuration. public static let `default` = LayoutOptions( hintRangeBoundaries: true, - searchUnconstrainedKeys: true + searchUnconstrainedKeys: true, + measureableStorageCache: true ) /// Enables aggressive cache hinting along the boundaries of the range between constraints and @@ -22,8 +23,16 @@ public struct LayoutOptions: Equatable { /// Layout contract for correct behavior. public var searchUnconstrainedKeys: Bool - public init(hintRangeBoundaries: Bool, searchUnconstrainedKeys: Bool) { + /// Allows caching the results of `MeasurableStorage` `sizeThatFits`. + public var measureableStorageCache: Bool + + public init( + hintRangeBoundaries: Bool, + searchUnconstrainedKeys: Bool, + measureableStorageCache: Bool + ) { self.hintRangeBoundaries = hintRangeBoundaries self.searchUnconstrainedKeys = searchUnconstrainedKeys + self.measureableStorageCache = measureableStorageCache } } diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index b9f642212..ef28b6344 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -1,6 +1,7 @@ import BlueprintUI import Foundation import UIKit +@_spi(CacheStorage) import BlueprintUI public struct AttributedLabel: Element, Hashable { @@ -132,10 +133,8 @@ public struct AttributedLabel: Element, Hashable { public var content: ElementContent { - // We create this outside of the measurement block so it's called fewer times. - let text = displayableAttributedText - - return ElementContent { constraint, environment -> CGSize in + ElementContent(cacheKey: self) { constraint, environment -> CGSize in + let text = displayableAttributedText let label = Self.prototypeLabel label.update(model: self, text: text, environment: environment, isMeasuring: true) return label.sizeThatFits(constraint.maximum) @@ -211,9 +210,6 @@ extension AttributedLabel { } } - // Store bounding shapes in this cache to avoid costly recalculations - private var boundingShapeCache: [Link: Link.BoundingShape] = [:] - override var accessibilityCustomRotors: [UIAccessibilityCustomRotor]? { set { assertionFailure("accessibilityCustomRotors is not settable.") } get { !linkElements.isEmpty ? [linkElements.accessibilityRotor(systemType: .link)] : [] } @@ -225,46 +221,6 @@ extension AttributedLabel { var urlHandler: URLHandler? - override init(frame: CGRect) { - super.init(frame: frame) - - if #available(iOS 17.0, *) { - registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { ( - view: LabelView, - previousTraitCollection: UITraitCollection - ) in - view.invalidateLinkBoundingShapeCaches() - } - } else { - NotificationCenter - .default - .addObserver( - self, - selector: #selector(sizeCategoryChanged(notification:)), - name: UIContentSizeCategory.didChangeNotification, - object: nil - ) - } - } - - deinit { - if #available(iOS 17.0, *) { - // Do nothing - } else { - NotificationCenter - .default - .removeObserver(self) - } - } - - @objc private func sizeCategoryChanged(notification: Notification) { - invalidateLinkBoundingShapeCaches() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - func update(model: AttributedLabel, text: NSAttributedString, environment: Environment, isMeasuring: Bool) { let previousAttributedText = isMeasuring ? nil : attributedText @@ -294,8 +250,6 @@ extension AttributedLabel { layoutDirection = environment.layoutDirection if !isMeasuring { - invalidateLinkBoundingShapeCaches() - if previousAttributedText != attributedText { links = attributedLinks(in: model.attributedText) + detectedDataLinks(in: model.attributedText) accessibilityLabel = accessibilityLabel( @@ -689,38 +643,8 @@ extension AttributedLabel { trackingLinks = nil applyLinkColors() } - - override func layoutSubviews() { - super.layoutSubviews() - - invalidateLinkBoundingShapeCaches() - } - - func boundingShape(for link: Link) -> Link.BoundingShape { - if let cachedShape = boundingShapeCache[link] { - return cachedShape - } - - let calculatedShape = link.calculateBoundingShape() - boundingShapeCache[link] = calculatedShape - return calculatedShape - } - - private func invalidateLinkBoundingShapeCaches() { - boundingShapeCache.removeAll() - } } -} -extension AttributedLabel.LabelView { - // Without this, we were seeing console messages like the following: - // "LabelView implements focusItemsInRect: - caching for linear focus movement is limited as long as this view is on screen." - // It's unclear as to why they are appearing despite using the API in the intended manner. - // To suppress the messages, we implemented this function much like Apple did with `UITableView`, - // `UICollectionView`, etc. - @objc private class func _supportsInvalidatingFocusCache() -> Bool { - true - } } extension AttributedLabel { @@ -749,10 +673,6 @@ extension AttributedLabel { } var boundingShape: BoundingShape { - container?.boundingShape(for: self) ?? calculateBoundingShape() - } - - fileprivate func calculateBoundingShape() -> BoundingShape { guard let container = container, let textStorage = container.makeTextStorage(), let layoutManager = textStorage.layoutManagers.first, @@ -855,7 +775,7 @@ extension AttributedLabel { override var accessibilityPath: UIBezierPath? { set { assertionFailure("cannot set accessibilityPath") } get { - if let path = link.boundingShape.path?.copy() as? UIBezierPath, let container = link.container { + if let path = link.boundingShape.path, let container = link.container { return UIAccessibility.convertToScreenCoordinates(path, in: container) } From 6802a228a13055c34ccf2ec7e915dbb2cc3185ba Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 16:12:12 -0700 Subject: [PATCH 03/34] Add skipUnneededSetNeedsViewHierarchyUpdates --- .../Sources/BlueprintView/BlueprintView.swift | 7 ++++++ BlueprintUI/Sources/Layout/LayoutMode.swift | 23 +++++++++++-------- .../Sources/Layout/LayoutOptions.swift | 16 ++++++++++--- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index 39e65c11b..402a3d723 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -54,6 +54,13 @@ public final class BlueprintView: UIView { didSet { // Shortcut: If both environments were empty, nothing changed. if oldValue.isEmpty && environment.isEmpty { return } + // Shortcut: If there are no changes to the environment, then, well, nothing changed. + if let layoutMode, layoutMode.options.skipUnneededSetNeedsViewHierarchyUpdates && oldValue.isEquivalent( + to: environment, + in: .all + ) { + return + } setNeedsViewHierarchyUpdate() } diff --git a/BlueprintUI/Sources/Layout/LayoutMode.swift b/BlueprintUI/Sources/Layout/LayoutMode.swift index da4f7f2d5..78a7741cc 100644 --- a/BlueprintUI/Sources/Layout/LayoutMode.swift +++ b/BlueprintUI/Sources/Layout/LayoutMode.swift @@ -11,7 +11,7 @@ import Foundation /// Changing the default will cause all instances of ``BlueprintView`` to be invalidated, and re- /// render their contents. /// -public struct LayoutMode: Equatable { +public struct LayoutMode: Hashable { public static var `default`: Self = .caffeinated { didSet { guard oldValue != .default else { return } @@ -41,15 +41,20 @@ public struct LayoutMode: Equatable { extension LayoutMode: CustomStringConvertible { public var description: String { - switch (options.hintRangeBoundaries, options.searchUnconstrainedKeys) { - case (true, true): - return "Caffeinated (hint+search)" - case (true, false): - return "Caffeinated (hint)" - case (false, true): - return "Caffeinated (search)" - case (false, false): + var optionsDescription: [String] = [] + if options.hintRangeBoundaries { + optionsDescription.append("hint") + } + if options.searchUnconstrainedKeys { + optionsDescription.append("search") + } + if options.skipUnneededSetNeedsViewHierarchyUpdates { + optionsDescription.append("needsViewHierarchyUpdates") + } + if optionsDescription.isEmpty { return "Caffeinated" + } else { + return "Caffeinated \(optionsDescription.joined(separator: "+"))" } } } diff --git a/BlueprintUI/Sources/Layout/LayoutOptions.swift b/BlueprintUI/Sources/Layout/LayoutOptions.swift index eb778295d..e1130cd21 100644 --- a/BlueprintUI/Sources/Layout/LayoutOptions.swift +++ b/BlueprintUI/Sources/Layout/LayoutOptions.swift @@ -4,12 +4,13 @@ import Foundation /// /// Generally these are only useful for experimenting with the performance profile of different /// element compositions, and you should stick with ``default``. -public struct LayoutOptions: Equatable { +public struct LayoutOptions: Hashable { /// The default configuration. public static let `default` = LayoutOptions( hintRangeBoundaries: true, - searchUnconstrainedKeys: true + searchUnconstrainedKeys: true, + skipUnneededSetNeedsViewHierarchyUpdates: true ) /// Enables aggressive cache hinting along the boundaries of the range between constraints and @@ -22,8 +23,17 @@ public struct LayoutOptions: Equatable { /// Layout contract for correct behavior. public var searchUnconstrainedKeys: Bool - public init(hintRangeBoundaries: Bool, searchUnconstrainedKeys: Bool) { + /// Allows skipping calls to setNeedsViewHierarchyUpdates when updating Environment, if the environment is + /// equilvalent to the prior value. + public var skipUnneededSetNeedsViewHierarchyUpdates: Bool + + public init( + hintRangeBoundaries: Bool, + searchUnconstrainedKeys: Bool, + skipUnneededSetNeedsViewHierarchyUpdates: Bool + ) { self.hintRangeBoundaries = hintRangeBoundaries self.searchUnconstrainedKeys = searchUnconstrainedKeys + self.skipUnneededSetNeedsViewHierarchyUpdates = skipUnneededSetNeedsViewHierarchyUpdates } } From 6429e564318d5726b4884e2803f5d82703fbc56b Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 16:17:52 -0700 Subject: [PATCH 04/34] Cache string normalization --- BlueprintUI/Sources/Layout/LayoutMode.swift | 23 ++-- .../Sources/Layout/LayoutOptions.swift | 15 ++- .../Sources/AttributedLabel.swift | 124 +++++------------- 3 files changed, 60 insertions(+), 102 deletions(-) diff --git a/BlueprintUI/Sources/Layout/LayoutMode.swift b/BlueprintUI/Sources/Layout/LayoutMode.swift index da4f7f2d5..0958860c7 100644 --- a/BlueprintUI/Sources/Layout/LayoutMode.swift +++ b/BlueprintUI/Sources/Layout/LayoutMode.swift @@ -11,7 +11,7 @@ import Foundation /// Changing the default will cause all instances of ``BlueprintView`` to be invalidated, and re- /// render their contents. /// -public struct LayoutMode: Equatable { +public struct LayoutMode: Hashable { public static var `default`: Self = .caffeinated { didSet { guard oldValue != .default else { return } @@ -41,15 +41,20 @@ public struct LayoutMode: Equatable { extension LayoutMode: CustomStringConvertible { public var description: String { - switch (options.hintRangeBoundaries, options.searchUnconstrainedKeys) { - case (true, true): - return "Caffeinated (hint+search)" - case (true, false): - return "Caffeinated (hint)" - case (false, true): - return "Caffeinated (search)" - case (false, false): + var optionsDescription: [String] = [] + if options.hintRangeBoundaries { + optionsDescription.append("hint") + } + if options.searchUnconstrainedKeys { + optionsDescription.append("search") + } + if options.stringNormalizationCache { + optionsDescription.append("stringNormalizationCache") + } + if optionsDescription.isEmpty { return "Caffeinated" + } else { + return "Caffeinated \(optionsDescription.joined(separator: "+"))" } } } diff --git a/BlueprintUI/Sources/Layout/LayoutOptions.swift b/BlueprintUI/Sources/Layout/LayoutOptions.swift index eb778295d..199d95693 100644 --- a/BlueprintUI/Sources/Layout/LayoutOptions.swift +++ b/BlueprintUI/Sources/Layout/LayoutOptions.swift @@ -4,12 +4,13 @@ import Foundation /// /// Generally these are only useful for experimenting with the performance profile of different /// element compositions, and you should stick with ``default``. -public struct LayoutOptions: Equatable { +public struct LayoutOptions: Hashable { /// The default configuration. public static let `default` = LayoutOptions( hintRangeBoundaries: true, - searchUnconstrainedKeys: true + searchUnconstrainedKeys: true, + stringNormalizationCache: true ) /// Enables aggressive cache hinting along the boundaries of the range between constraints and @@ -22,8 +23,16 @@ public struct LayoutOptions: Equatable { /// Layout contract for correct behavior. public var searchUnconstrainedKeys: Bool - public init(hintRangeBoundaries: Bool, searchUnconstrainedKeys: Bool) { + /// Caches results of AttributedLabel normalization process. + public var stringNormalizationCache: Bool + + public init( + hintRangeBoundaries: Bool, + searchUnconstrainedKeys: Bool, + stringNormalizationCache: Bool + ) { self.hintRangeBoundaries = hintRangeBoundaries self.searchUnconstrainedKeys = searchUnconstrainedKeys + self.stringNormalizationCache = stringNormalizationCache } } diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index b9f642212..4ec0d3139 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -1,6 +1,7 @@ import BlueprintUI import Foundation import UIKit +@_spi(CacheStorage) import BlueprintUI public struct AttributedLabel: Element, Hashable { @@ -114,9 +115,9 @@ public struct AttributedLabel: Element, Hashable { // MARK: Element /// The text to pass to the underlying `UILabel`, normalized for display if necessary. - var displayableAttributedText: NSAttributedString { + func displayableAttributedText(environment: Environment) -> NSAttributedString { if needsTextNormalization || linkDetectionTypes.isEmpty == false { - return attributedText.normalizingForView(with: numberOfLines) + return attributedText.normalizingForView(with: numberOfLines, environment: environment) } else { return attributedText } @@ -132,20 +133,19 @@ public struct AttributedLabel: Element, Hashable { public var content: ElementContent { - // We create this outside of the measurement block so it's called fewer times. - let text = displayableAttributedText - - return ElementContent { constraint, environment -> CGSize in + ElementContent { constraint, environment -> CGSize in + let text = displayableAttributedText(environment: environment) let label = Self.prototypeLabel label.update(model: self, text: text, environment: environment, isMeasuring: true) - return label.sizeThatFits(constraint.maximum) + let size = label.sizeThatFits(constraint.maximum) + return size } } public func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { // We create this outside of the application block so it's called fewer times. - let text = displayableAttributedText + let text = displayableAttributedText(environment: context.environment) return LabelView.describe { config in config.frameRoundingBehavior = .prioritizeSize @@ -211,9 +211,6 @@ extension AttributedLabel { } } - // Store bounding shapes in this cache to avoid costly recalculations - private var boundingShapeCache: [Link: Link.BoundingShape] = [:] - override var accessibilityCustomRotors: [UIAccessibilityCustomRotor]? { set { assertionFailure("accessibilityCustomRotors is not settable.") } get { !linkElements.isEmpty ? [linkElements.accessibilityRotor(systemType: .link)] : [] } @@ -225,46 +222,6 @@ extension AttributedLabel { var urlHandler: URLHandler? - override init(frame: CGRect) { - super.init(frame: frame) - - if #available(iOS 17.0, *) { - registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { ( - view: LabelView, - previousTraitCollection: UITraitCollection - ) in - view.invalidateLinkBoundingShapeCaches() - } - } else { - NotificationCenter - .default - .addObserver( - self, - selector: #selector(sizeCategoryChanged(notification:)), - name: UIContentSizeCategory.didChangeNotification, - object: nil - ) - } - } - - deinit { - if #available(iOS 17.0, *) { - // Do nothing - } else { - NotificationCenter - .default - .removeObserver(self) - } - } - - @objc private func sizeCategoryChanged(notification: Notification) { - invalidateLinkBoundingShapeCaches() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - func update(model: AttributedLabel, text: NSAttributedString, environment: Environment, isMeasuring: Bool) { let previousAttributedText = isMeasuring ? nil : attributedText @@ -294,8 +251,6 @@ extension AttributedLabel { layoutDirection = environment.layoutDirection if !isMeasuring { - invalidateLinkBoundingShapeCaches() - if previousAttributedText != attributedText { links = attributedLinks(in: model.attributedText) + detectedDataLinks(in: model.attributedText) accessibilityLabel = accessibilityLabel( @@ -689,38 +644,8 @@ extension AttributedLabel { trackingLinks = nil applyLinkColors() } - - override func layoutSubviews() { - super.layoutSubviews() - - invalidateLinkBoundingShapeCaches() - } - - func boundingShape(for link: Link) -> Link.BoundingShape { - if let cachedShape = boundingShapeCache[link] { - return cachedShape - } - - let calculatedShape = link.calculateBoundingShape() - boundingShapeCache[link] = calculatedShape - return calculatedShape - } - - private func invalidateLinkBoundingShapeCaches() { - boundingShapeCache.removeAll() - } } -} -extension AttributedLabel.LabelView { - // Without this, we were seeing console messages like the following: - // "LabelView implements focusItemsInRect: - caching for linear focus movement is limited as long as this view is on screen." - // It's unclear as to why they are appearing despite using the API in the intended manner. - // To suppress the messages, we implemented this function much like Apple did with `UITableView`, - // `UICollectionView`, etc. - @objc private class func _supportsInvalidatingFocusCache() -> Bool { - true - } } extension AttributedLabel { @@ -749,10 +674,6 @@ extension AttributedLabel { } var boundingShape: BoundingShape { - container?.boundingShape(for: self) ?? calculateBoundingShape() - } - - fileprivate func calculateBoundingShape() -> BoundingShape { guard let container = container, let textStorage = container.makeTextStorage(), let layoutManager = textStorage.layoutManagers.first, @@ -855,7 +776,7 @@ extension AttributedLabel { override var accessibilityPath: UIBezierPath? { set { assertionFailure("cannot set accessibilityPath") } get { - if let path = link.boundingShape.path?.copy() as? UIBezierPath, let container = link.container { + if let path = link.boundingShape.path, let container = link.container { return UIAccessibility.convertToScreenCoordinates(path, in: container) } @@ -964,7 +885,11 @@ extension NSAttributedString { NSRange(location: 0, length: length) } - fileprivate func normalizingForView(with numberOfLines: Int) -> NSAttributedString { + fileprivate func normalizingForView(with numberOfLines: Int, environment: Environment) -> NSAttributedString { + let key = AttributedStringNormalizationKey(label: self, lines: numberOfLines) + if environment.layoutMode.options.labelAttributedStringCache, let cached = environment.cacheStorage.attributedStringNormalizationCache[key] { + return cached + } var attributedText = AttributedText(self) for run in attributedText.runs { @@ -1000,7 +925,11 @@ extension NSAttributedString { attributedText.paragraphStyle = paragraphStyle } - return attributedText.attributedString + let resolved = attributedText.attributedString + if environment.layoutMode.options.labelAttributedStringCache { + environment.cacheStorage.attributedStringNormalizationCache[key] = resolved + } + return resolved } } @@ -1022,3 +951,18 @@ extension String { } } +fileprivate struct AttributedStringNormalizationKey: Hashable { + let label: NSAttributedString + let lines: Int +} + +extension CacheStorage { + private struct AttributedStringNormalizationCacheKey: CacheKey { + static var emptyValue: [AttributedStringNormalizationKey: NSAttributedString] = [:] + } + + fileprivate var attributedStringNormalizationCache: [AttributedStringNormalizationKey: NSAttributedString] { + get { self[AttributedStringNormalizationCacheKey.self] } + set { self[AttributedStringNormalizationCacheKey.self] = newValue } + } +} From 76aee1092cdb1842cdf4bf4e2072fa77fc4c18e0 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 16:18:06 -0700 Subject: [PATCH 05/34] Fix key name --- BlueprintUICommonControls/Sources/AttributedLabel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index 4ec0d3139..9d8bd1aa0 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -887,7 +887,7 @@ extension NSAttributedString { fileprivate func normalizingForView(with numberOfLines: Int, environment: Environment) -> NSAttributedString { let key = AttributedStringNormalizationKey(label: self, lines: numberOfLines) - if environment.layoutMode.options.labelAttributedStringCache, let cached = environment.cacheStorage.attributedStringNormalizationCache[key] { + if environment.layoutMode.options.stringNormalizationCache, let cached = environment.cacheStorage.attributedStringNormalizationCache[key] { return cached } var attributedText = AttributedText(self) @@ -926,7 +926,7 @@ extension NSAttributedString { } let resolved = attributedText.attributedString - if environment.layoutMode.options.labelAttributedStringCache { + if environment.layoutMode.options.stringNormalizationCache { environment.cacheStorage.attributedStringNormalizationCache[key] = resolved } return resolved From 44aa682ffa25ae55968027d90260394ba07427e0 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 16:25:23 -0700 Subject: [PATCH 06/34] Fix testkey --- BlueprintUI/Tests/EnvironmentTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BlueprintUI/Tests/EnvironmentTests.swift b/BlueprintUI/Tests/EnvironmentTests.swift index f354c6beb..7889ce8a7 100644 --- a/BlueprintUI/Tests/EnvironmentTests.swift +++ b/BlueprintUI/Tests/EnvironmentTests.swift @@ -307,7 +307,7 @@ private class TestView: UIView { var testValue = TestValue.defaultValue } -private enum TestValue { +private enum TestValue: Equatable { case defaultValue case wrong case right From 6dafc50ea5408cfe7358faac77ff75e2e6710eca Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 22:34:52 -0700 Subject: [PATCH 07/34] Fix HSC tests --- BlueprintUI/Tests/HintingSizeCacheTests.swift | 20 +++++++++++++------ BlueprintUI/Tests/StackTests.swift | 3 ++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/BlueprintUI/Tests/HintingSizeCacheTests.swift b/BlueprintUI/Tests/HintingSizeCacheTests.swift index fa38dbce6..da51dd66a 100644 --- a/BlueprintUI/Tests/HintingSizeCacheTests.swift +++ b/BlueprintUI/Tests/HintingSizeCacheTests.swift @@ -17,7 +17,11 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit each of the unique keys exactly once misses: boundaries, - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: false) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: false, + stringNormalizationCache: false + ) ) } @@ -45,7 +49,11 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to miss the first key, deduce the other boundaries, and miss the off keys misses: [key] + offKeys, - options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: false) + options: .init( + hintRangeBoundaries: true, + searchUnconstrainedKeys: false, + stringNormalizationCache: false + ) ) } @@ -67,7 +75,7 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit only the first key, and range-match the second misses: [SizeConstraint(width: .atMost(200), height: .unconstrained)], - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true, stringNormalizationCache: false) ) assertMisses( @@ -78,7 +86,7 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit only the first key, and range-match the second misses: [SizeConstraint(width: .unconstrained, height: .atMost(200))], - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true, stringNormalizationCache: false) ) let keys = [ @@ -92,7 +100,7 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we do not search the double-unconstrained key, so these are all misses misses: keys, - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true, stringNormalizationCache: false) ) } @@ -108,7 +116,7 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we will miss the first key, but can then range-match the others off of hinted boundary keys misses: [.unconstrained], - options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: true) + options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: true, stringNormalizationCache: false) ) } diff --git a/BlueprintUI/Tests/StackTests.swift b/BlueprintUI/Tests/StackTests.swift index cecb44b3f..b8f2cfd2f 100644 --- a/BlueprintUI/Tests/StackTests.swift +++ b/BlueprintUI/Tests/StackTests.swift @@ -1427,6 +1427,7 @@ extension VerticalAlignment { extension LayoutOptions { static let optimizationsDisabled: Self = .init( hintRangeBoundaries: false, - searchUnconstrainedKeys: false + searchUnconstrainedKeys: false, + stringNormalizationCache: false ) } From 56b751d9d76a6ad4c66556877deca72e7494ee00 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 22:35:39 -0700 Subject: [PATCH 08/34] Fix HSC tests --- BlueprintUI/Tests/HintingSizeCacheTests.swift | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/BlueprintUI/Tests/HintingSizeCacheTests.swift b/BlueprintUI/Tests/HintingSizeCacheTests.swift index fa38dbce6..2df1be4f1 100644 --- a/BlueprintUI/Tests/HintingSizeCacheTests.swift +++ b/BlueprintUI/Tests/HintingSizeCacheTests.swift @@ -17,7 +17,11 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit each of the unique keys exactly once misses: boundaries, - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: false) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: false, + skipUnneededSetNeedsViewHierarchyUpdates: false + ) ) } @@ -45,7 +49,11 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to miss the first key, deduce the other boundaries, and miss the off keys misses: [key] + offKeys, - options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: false) + options: .init( + hintRangeBoundaries: true, + searchUnconstrainedKeys: false, + skipUnneededSetNeedsViewHierarchyUpdates: false + ) ) } @@ -67,7 +75,11 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit only the first key, and range-match the second misses: [SizeConstraint(width: .atMost(200), height: .unconstrained)], - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: true, + skipUnneededSetNeedsViewHierarchyUpdates: false + ) ) assertMisses( @@ -78,7 +90,11 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit only the first key, and range-match the second misses: [SizeConstraint(width: .unconstrained, height: .atMost(200))], - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: true, + skipUnneededSetNeedsViewHierarchyUpdates: false + ) ) let keys = [ @@ -92,7 +108,11 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we do not search the double-unconstrained key, so these are all misses misses: keys, - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: true, + skipUnneededSetNeedsViewHierarchyUpdates: false + ) ) } @@ -108,7 +128,11 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we will miss the first key, but can then range-match the others off of hinted boundary keys misses: [.unconstrained], - options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: true) + options: .init( + hintRangeBoundaries: true, + searchUnconstrainedKeys: true, + skipUnneededSetNeedsViewHierarchyUpdates: false + ) ) } From 4a126e73e1360ec135faa4681b15c1e03d0f5d31 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 22:35:51 -0700 Subject: [PATCH 09/34] Fix HSC tests --- BlueprintUI/Tests/StackTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BlueprintUI/Tests/StackTests.swift b/BlueprintUI/Tests/StackTests.swift index cecb44b3f..1e2612bb9 100644 --- a/BlueprintUI/Tests/StackTests.swift +++ b/BlueprintUI/Tests/StackTests.swift @@ -1427,6 +1427,7 @@ extension VerticalAlignment { extension LayoutOptions { static let optimizationsDisabled: Self = .init( hintRangeBoundaries: false, - searchUnconstrainedKeys: false + searchUnconstrainedKeys: false, + skipUnneededSetNeedsViewHierarchyUpdates: false ) } From 499ee96de112d8b1c8dfc177d8b2ae4da1bf4899 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 22:37:11 -0700 Subject: [PATCH 10/34] Fix HSC tests --- BlueprintUI/Tests/HintingSizeCacheTests.swift | 16 ++++++++++------ BlueprintUI/Tests/StackTests.swift | 3 ++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/BlueprintUI/Tests/HintingSizeCacheTests.swift b/BlueprintUI/Tests/HintingSizeCacheTests.swift index fa38dbce6..0ac4e1b49 100644 --- a/BlueprintUI/Tests/HintingSizeCacheTests.swift +++ b/BlueprintUI/Tests/HintingSizeCacheTests.swift @@ -17,7 +17,11 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit each of the unique keys exactly once misses: boundaries, - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: false) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: false, + measureableStorageCache: false + ) ) } @@ -45,7 +49,7 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to miss the first key, deduce the other boundaries, and miss the off keys misses: [key] + offKeys, - options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: false) + options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: false, measureableStorageCache: false) ) } @@ -67,7 +71,7 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit only the first key, and range-match the second misses: [SizeConstraint(width: .atMost(200), height: .unconstrained)], - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true, measureableStorageCache: false) ) assertMisses( @@ -78,7 +82,7 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit only the first key, and range-match the second misses: [SizeConstraint(width: .unconstrained, height: .atMost(200))], - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true, measureableStorageCache: false) ) let keys = [ @@ -92,7 +96,7 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we do not search the double-unconstrained key, so these are all misses misses: keys, - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true, measureableStorageCache: false) ) } @@ -108,7 +112,7 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we will miss the first key, but can then range-match the others off of hinted boundary keys misses: [.unconstrained], - options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: true) + options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: true, measureableStorageCache: false) ) } diff --git a/BlueprintUI/Tests/StackTests.swift b/BlueprintUI/Tests/StackTests.swift index cecb44b3f..5bfec5dc8 100644 --- a/BlueprintUI/Tests/StackTests.swift +++ b/BlueprintUI/Tests/StackTests.swift @@ -1427,6 +1427,7 @@ extension VerticalAlignment { extension LayoutOptions { static let optimizationsDisabled: Self = .init( hintRangeBoundaries: false, - searchUnconstrainedKeys: false + searchUnconstrainedKeys: false, + measureableStorageCache: false ) } From 94e9b23589852c90ae79042ebdb9d378825b5bfe Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 22:43:47 -0700 Subject: [PATCH 11/34] Merge --- BlueprintUI/Sources/Layout/LayoutMode.swift | 6 +++ .../Sources/Layout/LayoutOptions.swift | 17 ++++++- BlueprintUI/Tests/HintingSizeCacheTests.swift | 44 ++++++++++++++++--- BlueprintUI/Tests/StackTests.swift | 4 +- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/BlueprintUI/Sources/Layout/LayoutMode.swift b/BlueprintUI/Sources/Layout/LayoutMode.swift index 5be6c16c9..2f60f62c9 100644 --- a/BlueprintUI/Sources/Layout/LayoutMode.swift +++ b/BlueprintUI/Sources/Layout/LayoutMode.swift @@ -51,6 +51,12 @@ extension LayoutMode: CustomStringConvertible { if options.measureableStorageCache { optionsDescription.append("measureableStorageCache") } + if options.stringNormalizationCache { + optionsDescription.append("stringNormalizationCache") + } + if options.skipUnneededSetNeedsViewHierarchyUpdates { + optionsDescription.append("needsViewHierarchyUpdates") + } if optionsDescription.isEmpty { return "Caffeinated" } else { diff --git a/BlueprintUI/Sources/Layout/LayoutOptions.swift b/BlueprintUI/Sources/Layout/LayoutOptions.swift index 2b066ccd1..eda5ea0da 100644 --- a/BlueprintUI/Sources/Layout/LayoutOptions.swift +++ b/BlueprintUI/Sources/Layout/LayoutOptions.swift @@ -10,7 +10,9 @@ public struct LayoutOptions: Hashable { public static let `default` = LayoutOptions( hintRangeBoundaries: true, searchUnconstrainedKeys: true, - measureableStorageCache: true + measureableStorageCache: true, + stringNormalizationCache: true, + skipUnneededSetNeedsViewHierarchyUpdates: true ) /// Enables aggressive cache hinting along the boundaries of the range between constraints and @@ -26,13 +28,24 @@ public struct LayoutOptions: Hashable { /// Allows caching the results of `MeasurableStorage` `sizeThatFits`. public var measureableStorageCache: Bool + /// Caches results of AttributedLabel normalization process. + public var stringNormalizationCache: Bool + + /// Allows skipping calls to setNeedsViewHierarchyUpdates when updating Environment, if the environment is + /// equilvalent to the prior value. + public var skipUnneededSetNeedsViewHierarchyUpdates: Bool + public init( hintRangeBoundaries: Bool, searchUnconstrainedKeys: Bool, - measureableStorageCache: Bool + measureableStorageCache: Bool, + stringNormalizationCache: Bool, + skipUnneededSetNeedsViewHierarchyUpdates: Bool ) { self.hintRangeBoundaries = hintRangeBoundaries self.searchUnconstrainedKeys = searchUnconstrainedKeys self.measureableStorageCache = measureableStorageCache + self.stringNormalizationCache = stringNormalizationCache + self.skipUnneededSetNeedsViewHierarchyUpdates = skipUnneededSetNeedsViewHierarchyUpdates } } diff --git a/BlueprintUI/Tests/HintingSizeCacheTests.swift b/BlueprintUI/Tests/HintingSizeCacheTests.swift index 0ac4e1b49..634f117e9 100644 --- a/BlueprintUI/Tests/HintingSizeCacheTests.swift +++ b/BlueprintUI/Tests/HintingSizeCacheTests.swift @@ -20,7 +20,9 @@ final class HintingSizeCacheTests: XCTestCase { options: .init( hintRangeBoundaries: false, searchUnconstrainedKeys: false, - measureableStorageCache: false + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false ) ) } @@ -49,7 +51,13 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to miss the first key, deduce the other boundaries, and miss the off keys misses: [key] + offKeys, - options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: false, measureableStorageCache: false) + options: .init( + hintRangeBoundaries: true, + searchUnconstrainedKeys: false, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false + ) ) } @@ -71,7 +79,13 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit only the first key, and range-match the second misses: [SizeConstraint(width: .atMost(200), height: .unconstrained)], - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true, measureableStorageCache: false) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: true, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false + ) ) assertMisses( @@ -82,7 +96,13 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit only the first key, and range-match the second misses: [SizeConstraint(width: .unconstrained, height: .atMost(200))], - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true, measureableStorageCache: false) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: true, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false + ) ) let keys = [ @@ -96,7 +116,13 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we do not search the double-unconstrained key, so these are all misses misses: keys, - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true, measureableStorageCache: false) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: true, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false + ) ) } @@ -112,7 +138,13 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we will miss the first key, but can then range-match the others off of hinted boundary keys misses: [.unconstrained], - options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: true, measureableStorageCache: false) + options: .init( + hintRangeBoundaries: true, + searchUnconstrainedKeys: true, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false + ) ) } diff --git a/BlueprintUI/Tests/StackTests.swift b/BlueprintUI/Tests/StackTests.swift index 5bfec5dc8..d647c4ed9 100644 --- a/BlueprintUI/Tests/StackTests.swift +++ b/BlueprintUI/Tests/StackTests.swift @@ -1428,6 +1428,8 @@ extension LayoutOptions { static let optimizationsDisabled: Self = .init( hintRangeBoundaries: false, searchUnconstrainedKeys: false, - measureableStorageCache: false + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false ) } From 1c45f7f7d756798e8aa7b6e6a384cae709a9bdb7 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 22:51:26 -0700 Subject: [PATCH 12/34] LASC --- BlueprintUI/Sources/Layout/LayoutMode.swift | 3 +++ BlueprintUI/Sources/Layout/LayoutOptions.swift | 10 ++++++++-- BlueprintUI/Tests/HintingSizeCacheTests.swift | 18 ++++++++++++------ BlueprintUI/Tests/StackTests.swift | 3 ++- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/BlueprintUI/Sources/Layout/LayoutMode.swift b/BlueprintUI/Sources/Layout/LayoutMode.swift index 2f60f62c9..53b892c5a 100644 --- a/BlueprintUI/Sources/Layout/LayoutMode.swift +++ b/BlueprintUI/Sources/Layout/LayoutMode.swift @@ -57,6 +57,9 @@ extension LayoutMode: CustomStringConvertible { if options.skipUnneededSetNeedsViewHierarchyUpdates { optionsDescription.append("needsViewHierarchyUpdates") } + if options.labelAttributedStringCache { + optionsDescription.append("labelAttributedStringCache") + } if optionsDescription.isEmpty { return "Caffeinated" } else { diff --git a/BlueprintUI/Sources/Layout/LayoutOptions.swift b/BlueprintUI/Sources/Layout/LayoutOptions.swift index eda5ea0da..8ff17dbc4 100644 --- a/BlueprintUI/Sources/Layout/LayoutOptions.swift +++ b/BlueprintUI/Sources/Layout/LayoutOptions.swift @@ -12,7 +12,8 @@ public struct LayoutOptions: Hashable { searchUnconstrainedKeys: true, measureableStorageCache: true, stringNormalizationCache: true, - skipUnneededSetNeedsViewHierarchyUpdates: true + skipUnneededSetNeedsViewHierarchyUpdates: true, + labelAttributedStringCache: true ) /// Enables aggressive cache hinting along the boundaries of the range between constraints and @@ -35,17 +36,22 @@ public struct LayoutOptions: Hashable { /// equilvalent to the prior value. public var skipUnneededSetNeedsViewHierarchyUpdates: Bool + /// Caches MarketLabel attributed string generation + public var labelAttributedStringCache: Bool + public init( hintRangeBoundaries: Bool, searchUnconstrainedKeys: Bool, measureableStorageCache: Bool, stringNormalizationCache: Bool, - skipUnneededSetNeedsViewHierarchyUpdates: Bool + skipUnneededSetNeedsViewHierarchyUpdates: Bool, + labelAttributedStringCache: Bool ) { self.hintRangeBoundaries = hintRangeBoundaries self.searchUnconstrainedKeys = searchUnconstrainedKeys self.measureableStorageCache = measureableStorageCache self.stringNormalizationCache = stringNormalizationCache self.skipUnneededSetNeedsViewHierarchyUpdates = skipUnneededSetNeedsViewHierarchyUpdates + self.labelAttributedStringCache = labelAttributedStringCache } } diff --git a/BlueprintUI/Tests/HintingSizeCacheTests.swift b/BlueprintUI/Tests/HintingSizeCacheTests.swift index 634f117e9..08f4e28e5 100644 --- a/BlueprintUI/Tests/HintingSizeCacheTests.swift +++ b/BlueprintUI/Tests/HintingSizeCacheTests.swift @@ -22,7 +22,8 @@ final class HintingSizeCacheTests: XCTestCase { searchUnconstrainedKeys: false, measureableStorageCache: false, stringNormalizationCache: false, - skipUnneededSetNeedsViewHierarchyUpdates: false + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false ) ) } @@ -56,7 +57,8 @@ final class HintingSizeCacheTests: XCTestCase { searchUnconstrainedKeys: false, measureableStorageCache: false, stringNormalizationCache: false, - skipUnneededSetNeedsViewHierarchyUpdates: false + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false ) ) } @@ -84,7 +86,8 @@ final class HintingSizeCacheTests: XCTestCase { searchUnconstrainedKeys: true, measureableStorageCache: false, stringNormalizationCache: false, - skipUnneededSetNeedsViewHierarchyUpdates: false + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false ) ) @@ -101,7 +104,8 @@ final class HintingSizeCacheTests: XCTestCase { searchUnconstrainedKeys: true, measureableStorageCache: false, stringNormalizationCache: false, - skipUnneededSetNeedsViewHierarchyUpdates: false + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false ) ) @@ -121,7 +125,8 @@ final class HintingSizeCacheTests: XCTestCase { searchUnconstrainedKeys: true, measureableStorageCache: false, stringNormalizationCache: false, - skipUnneededSetNeedsViewHierarchyUpdates: false + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false ) ) } @@ -143,7 +148,8 @@ final class HintingSizeCacheTests: XCTestCase { searchUnconstrainedKeys: true, measureableStorageCache: false, stringNormalizationCache: false, - skipUnneededSetNeedsViewHierarchyUpdates: false + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false ) ) } diff --git a/BlueprintUI/Tests/StackTests.swift b/BlueprintUI/Tests/StackTests.swift index d647c4ed9..2e88dbf1b 100644 --- a/BlueprintUI/Tests/StackTests.swift +++ b/BlueprintUI/Tests/StackTests.swift @@ -1430,6 +1430,7 @@ extension LayoutOptions { searchUnconstrainedKeys: false, measureableStorageCache: false, stringNormalizationCache: false, - skipUnneededSetNeedsViewHierarchyUpdates: false + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false ) } From 9afcdd1339e6a072f0f6f3fe4c7f67e39846bdb7 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Fri, 25 Jul 2025 00:23:00 -0700 Subject: [PATCH 13/34] Merge --- .../Sources/BlueprintView/BlueprintView.swift | 7 + .../Sources/Element/ElementContent.swift | 15 +- .../Sources/Element/MeasurableStorage.swift | 44 +++- .../Environment/Cache/CacheStorage.swift | 57 ++++- .../Cache/EnvironmentEntangledCache.swift | 43 ---- .../Environment/Cache/ValidatingCache.swift | 194 ++++++++++++++++++ .../Sources/Environment/Environment.swift | 102 +++++++-- .../Sources/Environment/EnvironmentKey.swift | 16 +- .../Keys/AccessibilityLinkKey.swift | 2 +- .../Sources/Internal/Equivalency.swift | 46 ++++- BlueprintUI/Sources/Internal/Logger.swift | 65 +++++- .../EnvironmentEntangledCacheTests.swift | 7 + .../Tests/EnvironmentEquivalencyTests.swift | 172 ++++++++++++++++ .../Sources/AttributedLabel+Environment.swift | 2 +- .../Sources/AttributedLabel.swift | 4 +- SampleApp/Sources/PostsViewController.swift | 2 +- 16 files changed, 673 insertions(+), 105 deletions(-) delete mode 100644 BlueprintUI/Sources/Environment/Cache/EnvironmentEntangledCache.swift create mode 100644 BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift create mode 100644 BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift create mode 100644 BlueprintUI/Tests/EnvironmentEquivalencyTests.swift diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index 402a3d723..29652434d 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -95,6 +95,13 @@ public final class BlueprintView: UIView { if oldValue == nil && element == nil { return } + if let layoutMode, layoutMode.options.skipUnneededSetNeedsViewHierarchyUpdates, let contextuallyEquivalent = element as? ContextuallyEquivalent, contextuallyEquivalent.isEquivalent( + to: oldValue as? ContextuallyEquivalent, + in: .all + ) { + return + } + cacheStorage = Environment.CacheStorageEnvironmentKey.defaultValue Logger.logElementAssigned(view: self) diff --git a/BlueprintUI/Sources/Element/ElementContent.swift b/BlueprintUI/Sources/Element/ElementContent.swift index 7563a6cc3..396483808 100644 --- a/BlueprintUI/Sources/Element/ElementContent.swift +++ b/BlueprintUI/Sources/Element/ElementContent.swift @@ -242,13 +242,22 @@ extension ElementContent { /// Initializes a new `ElementContent` with no children that delegates to the provided measure function. /// - /// - parameter cacheKey: If present, a key with which the measureFunction result will be cached. The key will be hashed and discarded to avoid memory bloat. /// - parameter measureFunction: How to measure the `ElementContent` in the given `SizeConstraint` and `Environment`. public init( - cacheKey: AnyHashable? = nil, measureFunction: @escaping (SizeConstraint, Environment) -> CGSize ) { - storage = MeasurableStorage(cacheKey: cacheKey, measurer: measureFunction) + storage = MeasurableStorage(measurer: measureFunction) + } + + /// Initializes a new `ElementContent` with no children that delegates to the provided measure function. + /// + /// - parameter validationKey: If present, measureFunction will attempt to cache sizing based on the path of the node. validationKey will be evaluated to ensure that the result is valid. + /// - parameter measureFunction: How to measure the `ElementContent` in the given `SizeConstraint` and `Environment`. + public init( + validationKey: some ContextuallyEquivalent, + measureFunction: @escaping (SizeConstraint, Environment) -> CGSize + ) { + storage = MeasurableStorage(validationKey: validationKey, measurer: measureFunction) } /// Initializes a new `ElementContent` with no children that uses the provided intrinsic size for measuring. diff --git a/BlueprintUI/Sources/Element/MeasurableStorage.swift b/BlueprintUI/Sources/Element/MeasurableStorage.swift index 5eb48a5df..88432b688 100644 --- a/BlueprintUI/Sources/Element/MeasurableStorage.swift +++ b/BlueprintUI/Sources/Element/MeasurableStorage.swift @@ -7,8 +7,18 @@ struct MeasurableStorage: ContentStorage { let childCount = 0 - let cacheKey: AnyHashable? + let validationKey: AnyContextuallyEquivalent? let measurer: (SizeConstraint, Environment) -> CGSize + + init(validationKey: some ContextuallyEquivalent, measurer: @escaping (SizeConstraint, Environment) -> CGSize) { + self.validationKey = AnyContextuallyEquivalent(validationKey) + self.measurer = measurer + } + + init(measurer: @escaping (SizeConstraint, Environment) -> CGSize) { + validationKey = nil + self.measurer = measurer + } } extension MeasurableStorage: CaffeinatedContentStorage { @@ -18,14 +28,16 @@ extension MeasurableStorage: CaffeinatedContentStorage { environment: Environment, node: LayoutTreeNode ) -> CGSize { - guard environment.layoutMode.options.measureableStorageCache, let cacheKey else { + guard environment.layoutMode.options.measureableStorageCache, let validationKey else { return measurer(proposal, environment) } - let key = MeasurableSizeKey(model: cacheKey, max: proposal.maximum) + + let key = MeasurableSizeKey(path: node.path, max: proposal.maximum) return environment.cacheStorage.measurableStorageCache.retrieveOrCreate( key: key, environment: environment, - context: .internalElementLayout + validationValue: validationKey, + context: .elementSizing, ) { measurer(proposal, environment) } @@ -43,13 +55,15 @@ extension MeasurableStorage: CaffeinatedContentStorage { extension MeasurableStorage { fileprivate struct MeasurableSizeKey: Hashable { - let hashValue: Int - init(model: AnyHashable, max: CGSize) { - var hasher = Hasher() - model.hash(into: &hasher) + + let path: String + let max: CGSize + + func hash(into hasher: inout Hasher) { + path.hash(into: &hasher) max.hash(into: &hasher) - hashValue = hasher.finalize() } + } } @@ -57,10 +71,18 @@ extension MeasurableStorage { extension CacheStorage { private struct MeasurableStorageCacheKey: CacheKey { - static var emptyValue = EnvironmentEntangledCache() + static var emptyValue = EnvironmentAndValueValidatingCache< + MeasurableStorage.MeasurableSizeKey, + CGSize, + AnyContextuallyEquivalent + >() } - fileprivate var measurableStorageCache: EnvironmentEntangledCache { + fileprivate var measurableStorageCache: EnvironmentAndValueValidatingCache< + MeasurableStorage.MeasurableSizeKey, + CGSize, + AnyContextuallyEquivalent + > { get { self[MeasurableStorageCacheKey.self] } set { self[MeasurableStorageCacheKey.self] = newValue } } diff --git a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift index 27a0cbb39..b1763ad96 100644 --- a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift +++ b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift @@ -1,4 +1,7 @@ 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 @@ -7,7 +10,19 @@ import Foundation // Optional name to distinguish between instances for debugging purposes. public var name: String? = nil - private var storage: [ObjectIdentifier: Any] = [:] + 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 { @@ -19,25 +34,61 @@ import Foundation } public var debugDescription: String { - if let name { + 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[internal: CacheStorageEnvironmentKey.self] } set { self[internal: CacheStorageEnvironmentKey.self] = newValue } } } + +/// A frozen environment is immutable copy of the comparable elements of an Environment struct. +struct FrozenEnvironment { + + // Fingerprint used for referencing previously compared environments. + let fingerprint: ComparableFingerprint + let values: [Environment.Keybox: Any] + +} + +/// 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 { + + 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 + } + +} + diff --git a/BlueprintUI/Sources/Environment/Cache/EnvironmentEntangledCache.swift b/BlueprintUI/Sources/Environment/Cache/EnvironmentEntangledCache.swift deleted file mode 100644 index 3e2da853c..000000000 --- a/BlueprintUI/Sources/Environment/Cache/EnvironmentEntangledCache.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation - -/// Simple dictionary-like key value cache with enforcement around environment consistency. -@_spi(CacheStorage) public struct EnvironmentEntangledCache: Sendable { - - private var storage: [Key: (Environment, Value)] = [:] - - public subscript(uncheckedKey key: Key) -> Value? { - storage[key]?.1 - } - - /// Retrieves a value if one exists in the cache for a given environment and equivalency context. - /// - Parameters: - /// - key: The key to look up. - /// - environment: The environment being evaluated. - /// - context: The context in which the environment is being evaluated in. - /// - create: A closure to create a fresh value if no suitable cached value is available. - /// - Returns: A cached or freshly created value. - /// - Note: If no value exists for the key, or a value exists for the key but the environment does not meet the require equivalency, any existing value will be evicted from the cache and a fresh value will be created and stored. - public mutating func retrieveOrCreate( - key: Key, - environment: Environment, - context: EquivalencyContext, - create: () -> Value - ) -> Value { - if let existing = storage[key] { - if existing.0.isEquivalent(to: environment, in: context) { - return existing.1 - } else { - storage.removeValue(forKey: key) - } - } - let fresh = create() - storage[key] = (environment, fresh) - return fresh - } - - public mutating func removeValue(forKey key: Key) -> Value? { - storage.removeValue(forKey: key)?.1 - } - -} - diff --git a/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift new file mode 100644 index 000000000..07a887ec3 --- /dev/null +++ b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift @@ -0,0 +1,194 @@ +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: () -> Value + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isEquivalent(to: $0, in: context) + } create: { + (create(), environment.frozen) + } + } + +} + +/// 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: () -> (Value, AdditionalValidationData) + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isEquivalent(to: $0.0, in: context) && validate($0.1) + } create: { + let (fresh, additional) = create() + return (fresh, (environment.frozen, 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: () -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0.isEquivalent(to: validationValue, in: context) + } create: { + (create(), 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: () -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0 == validationValue + } create: { + (create(), 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 6fae5ae06..762957845 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -40,16 +40,12 @@ public struct Environment { /// Each key will return its default value. public static let empty = Environment() - private var values: [Keybox: Any] = [:] { - didSet { - fingerprint = UUID() - } - } + // Fingerprint used for referencing previously compared environments. + private var fingerprint = ComparableFingerprint() - private var internalValues: [ObjectIdentifier: Any] = [:] + private var values: [Keybox: Any] = [:] - // Fingerprint used for referencing previously compared environments. - fileprivate var fingerprint: UUID = UUID() + private var internalValues: [ObjectIdentifier: Any] = [:] /// Gets or sets an environment value by its key. public subscript(key: Key.Type) -> Key.Value where Key: EnvironmentKey { @@ -57,7 +53,12 @@ public struct Environment { self[Keybox(key)] as! Key.Value } set { - values[Keybox(key)] = newValue + let keybox = Keybox(key) + let oldValue = values[keybox] + values[keybox] = newValue + if !keybox.isEquivalent(newValue, oldValue, .all) { + fingerprint.modified() + } } } @@ -67,7 +68,7 @@ public struct Environment { public subscript(internal key: Key.Type) -> Key.Value where Key: EnvironmentKey { get { - internalValues[ObjectIdentifier(key)] as! Key.Value + internalValues[ObjectIdentifier(key), default: key.defaultValue] as! Key.Value } set { internalValues[ObjectIdentifier(key)] = newValue @@ -85,40 +86,101 @@ public struct Environment { func merged(prioritizing other: Environment) -> Environment { var merged = self merged.values.merge(other.values) { $1 } + merged.fingerprint.modified() + return merged + } + + var frozen: FrozenEnvironment { + FrozenEnvironment(fingerprint: fingerprint, values: values) + } + + func thawing(frozen: FrozenEnvironment) -> Environment { + var merged = self + merged.values = frozen.values + merged.fingerprint = frozen.fingerprint return merged } + + } extension Environment: ContextuallyEquivalent { - public func isEquivalent(to other: Environment?, in context: EquivalencyContext) -> Bool { + public func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { guard let other else { return false } - if fingerprint == other.fingerprint { return true } - if let evaluated = cacheStorage.environmentComparisonCache[other.fingerprint] ?? other.cacheStorage.environmentComparisonCache[fingerprint], let result = evaluated[context] { - return result + if fingerprint.isEquivalent(to: other.fingerprint) { return true } + if let evaluated = cacheStorage.environmentComparisonCache[other.fingerprint, context] ?? other.cacheStorage.environmentComparisonCache[ + fingerprint, + context + ] { + return evaluated } let keys = Set(values.keys).union(other.values.keys) for key in keys { guard key.isEquivalent(self[key], other[key], context) else { - cacheStorage.environmentComparisonCache[other.fingerprint, default: [:]][context] = false + cacheStorage.environmentComparisonCache[other.fingerprint, context] = false return false } } - cacheStorage.environmentComparisonCache[other.fingerprint, default: [:]][context] = true + cacheStorage.environmentComparisonCache[other.fingerprint, context] = true return true } -} + func isEquivalent(to other: FrozenEnvironment?, in context: EquivalencyContext) -> Bool { + guard let other else { return false } + // We don't even need to thaw the environment if the fingerprints match. + if frozen.fingerprint.isEquivalent(to: fingerprint) { return true } + return isEquivalent(to: thawing(frozen: frozen), in: context) + } + +} extension CacheStorage { + fileprivate struct EnvironmentFingerprintCache { + + typealias EquivalencyResult = [EquivalencyContext: Bool] + var storage: [ComparableFingerprint.Value: [EquivalencyContext: Bool]] = [:] + + public subscript(fingerprint: ComparableFingerprint, context: EquivalencyContext) -> Bool? { + get { + if let exact = storage[fingerprint.value]?[context] { + return exact + } else if let allComparisons = storage[fingerprint.value] { + 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[fingerprint.value, default: [:]][context] = newValue + } + } + + } + /// A cache of previously compared environments and their results. private struct EnvironmentComparisonCacheKey: CacheKey { - static var emptyValue: [UUID: [EquivalencyContext: Bool]] = [:] + static var emptyValue = EnvironmentFingerprintCache() } - fileprivate var environmentComparisonCache: [UUID: [EquivalencyContext: Bool]] { + fileprivate var environmentComparisonCache: EnvironmentFingerprintCache { get { self[EnvironmentComparisonCacheKey.self] } set { self[EnvironmentComparisonCacheKey.self] = newValue } } @@ -128,7 +190,7 @@ extension CacheStorage { extension Environment { /// Lightweight key type eraser. - fileprivate struct Keybox: Hashable, CustomStringConvertible { + struct Keybox: Hashable, CustomStringConvertible { let objectIdentifier: ObjectIdentifier let type: any EnvironmentKey.Type diff --git a/BlueprintUI/Sources/Environment/EnvironmentKey.swift b/BlueprintUI/Sources/Environment/EnvironmentKey.swift index 14cf5ba49..a61b0a487 100644 --- a/BlueprintUI/Sources/Environment/EnvironmentKey.swift +++ b/BlueprintUI/Sources/Environment/EnvironmentKey.swift @@ -49,16 +49,16 @@ extension EnvironmentKey where Value: Equatable { /// - contexts: Contexts in which to always return true for equivalency. /// - lhs: The left hand side value being compared. /// - rhs: The right hand side value being compared. - /// - context: The context in which the values are currently being compared. + /// - evaluatingContext: The context in which the values are currently being compared. /// - Returns: Whether or not the two values are equivalent in the specified context. /// - Note: This is often used for convenience in cases where layout is unaffected, e.g., for an environment value like dark mode, which will have no effect on internal or external layout. public static func alwaysEquivalentIn( _ contexts: Set, lhs: Value, rhs: Value, - context context: EquivalencyContext + evaluatingContext: EquivalencyContext ) -> Bool { - if contexts.contains(context) { + if contexts.contains(evaluatingContext) { true } else { lhs == rhs @@ -78,19 +78,19 @@ extension EnvironmentKey where Value: ContextuallyEquivalent { /// - contexts: Contexts in which to always return true for equivalency. /// - lhs: The left hand side value being compared. /// - rhs: The right hand side value being compared. - /// - context: The context in which the values are currently being compared. + /// - evaluatingContext: The context in which the values are currently being compared. /// - Returns: Whether or not the two values are equivalent in the specified context. /// - Note: This is often used for convenience in cases where layout is unaffected, e.g., for an environment value like dark mode, which will have no effect on internal or external layout. public static func alwaysEquivalentIn( _ contexts: Set, lhs: Value, rhs: Value, - context context: EquivalencyContext + evaluatingContext: EquivalencyContext ) -> Bool { - if contexts.contains(context) { + if contexts.contains(evaluatingContext) { true } else { - lhs.isEquivalent(to: rhs, in: context) + lhs.isEquivalent(to: rhs, in: evaluatingContext) } } @@ -105,7 +105,7 @@ extension EnvironmentKey { /// - Returns: Whether or not the value is equivalent in the context. public static func alwaysEquivalentIn( _ contexts: Set, - in evaluatingContext: EquivalencyContext + evaluatingContext: EquivalencyContext ) -> Bool { contexts.contains(evaluatingContext) } diff --git a/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift b/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift index bd667b3e0..71bb54a57 100644 --- a/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift +++ b/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift @@ -7,7 +7,7 @@ extension Environment { } static func isEquivalent(lhs: String?, rhs: String?, in context: EquivalencyContext) -> Bool { - alwaysEquivalentIn([.overallLayout, .internalElementLayout], lhs: lhs, rhs: rhs, context: context) + alwaysEquivalentIn([.elementSizing], lhs: lhs, rhs: rhs, evaluatingContext: context) } } diff --git a/BlueprintUI/Sources/Internal/Equivalency.swift b/BlueprintUI/Sources/Internal/Equivalency.swift index 8f70fc237..62693f7b2 100644 --- a/BlueprintUI/Sources/Internal/Equivalency.swift +++ b/BlueprintUI/Sources/Internal/Equivalency.swift @@ -2,31 +2,45 @@ import Foundation // A context in which to evaluate whether or not two values are equivalent. public enum EquivalencyContext: Hashable, Sendable, CaseIterable { - // The two values are identicial in every respect. + + /// The two values are identicial in every respect that could affect displayed output. case all - // The two values are equivalent in all aspects that would affect layout. - case overallLayout - // The two values are equivalent in all aspects that would affect layout internally. - case internalElementLayout + + // More fine-grained contexts: + + /// The two values are equivalent in all aspects that would affect the size of the element. + /// - Warning:Non-obvious things may affect element-sizing – for example, setting a time zone may seem like something that would only affect date calculations, but can result in different text being displayed, and therefore affect sizing. Consider carefully whether you are truly affecting sizing or not. + case elementSizing } public protocol ContextuallyEquivalent { - /// Allows a type to express equivilancy within certain contexts. For example, an Environment that represents dark mode would be equivilant to an Environment that represents light mode in a `layout` context, but not in `all` contexts. + /// Allows a type to express equivilancy within certain contexts. For example, an Environment that represents dark mode would be equivalent to an Environment that represents light mode in a `elementSizing` context, but not in `all` contexts. /// - Parameters: /// - other: The instance of the type being compared against. /// - context: The context to compare within. /// - Returns: Whether or not the other instance is equivalent in the specified context. + /// - Note: Equivilancy within a given context is transitive – that is, if value A is equivalent to value B in a given context, and B is equivalent to C in that same context, A will be considered equivalent to C with that context. func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool } +extension ContextuallyEquivalent { + + /// Convenience equivalency check passing in .all for context. + /// - other: The instance of the type being compared against. + /// - Returns: Whether or not the other instance is equivalent in all contexts. + public func isEquivalent(to other: Self?) -> Bool { + isEquivalent(to: other, in: .all) + } + +} extension ContextuallyEquivalent { // Allows comparison between types which may or may not be equivalent. @_disfavoredOverload - func isEquivalent(to other: (any ContextuallyEquivalent)?, in context: EquivalencyContext) -> Bool { + public func isEquivalent(to other: (any ContextuallyEquivalent)?, in context: EquivalencyContext) -> Bool { isEquivalent(to: other as? Self, in: context) } @@ -35,8 +49,24 @@ extension ContextuallyEquivalent { // Default implementation that always returns strict equivalency. extension ContextuallyEquivalent where Self: Equatable { - func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { + public func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { self == other } } + +public struct AnyContextuallyEquivalent: ContextuallyEquivalent { + + let base: Any + + public init(_ value: some ContextuallyEquivalent) { + base = value + } + + 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) + } + +} + diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index b2074465a..b50f2cb40 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -2,9 +2,11 @@ import Foundation import os.log /// Namespace for logging helpers -enum Logger {} +enum Logger { + fileprivate static let signposter = OSSignposter(logHandle: .active) +} -/// BlueprintView signposts +// MARK: - BlueprintView signposts extension Logger { static func logLayoutStart(view: BlueprintView) { @@ -100,7 +102,8 @@ extension Logger { } } -/// Measuring signposts +// MARK: - HintingSizeCache signposts + extension Logger { static func logMeasureStart(object: AnyObject, description: String, constraint: SizeConstraint) { @@ -185,10 +188,64 @@ extension Logger { ) } - // MARK: Utilities + +} + +// MARK: - CacheStorage + +extension Logger { + + // MARK: ValidatingCache + + static func logValidatingCacheValidationStart(key: some Hashable) -> OSSignpostIntervalState { + signposter.beginInterval("ValidatingCache validation", id: key.signpost, "Start: \(String(describing: key))") + } + + static func logValidatingCacheValidationEnd(_ token: OSSignpostIntervalState, key: some Hashable) { + signposter.endInterval("ValidatingCache validation", token, "\(String(describing: key))") + } + + static func logValidatingCacheFreshValueCreationStart(key: some Hashable) -> OSSignpostIntervalState { + signposter.beginInterval("ValidatingCache fresh value creation", id: key.signpost, "\(String(describing: key))") + } + + static func logValidatingCacheFreshValueCreationEnd(_ token: OSSignpostIntervalState, key: some Hashable) { + signposter.endInterval("ValidatingCache fresh value creation", token, "\(String(describing: key))") + } + + static func logValidatingCacheKeyMiss(key: some Hashable) { + signposter.emitEvent("ValidatingCache key miss", id: key.signpost, "\(String(describing: key))") + } + + static func logValidatingCacheKeyHit(key: some Hashable) { + signposter.emitEvent("ValidatingCache key hit", id: key.signpost, "\(String(describing: key))") + } + + static func logValidatingCacheHitAndValidationSuccess(key: some Hashable) { + signposter.emitEvent("ValidatingCache validation success", id: key.signpost, "\(String(describing: key))") + } + + static func logValidatingCacheHitAndValidationFailure(key: some Hashable) { + signposter.emitEvent("ValidatingCache validation failure", id: key.signpost, "\(String(describing: key))") + } + +} + +extension Hashable { + + 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/EnvironmentEntangledCacheTests.swift b/BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift new file mode 100644 index 000000000..2ea946699 --- /dev/null +++ b/BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift @@ -0,0 +1,7 @@ +// +// EnvironmentEntangledCacheTests.swift +// Development +// +// Created by Max Goedjen on 7/23/25. +// + diff --git a/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift new file mode 100644 index 000000000..c1222c760 --- /dev/null +++ b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift @@ -0,0 +1,172 @@ +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() { + var a = Environment() + let b = a + a[CountingKey.self] = 1 + #expect(CountingKey.comparisonCount == 0) + #expect(!a.isEquivalent(to: b)) + // First comparison should call comparison method + #expect(CountingKey.comparisonCount == 1) + + #expect(!a.isEquivalent(to: b)) + // Subsequent comparison should be cached + #expect(CountingKey.comparisonCount == 1) + + #expect(!b.isEquivalent(to: a)) + // Reversed order should still be cached + #expect(CountingKey.comparisonCount == 1) + + // Copying without mutation should preserve fingerprint, and be cached. + let c = b + #expect(CountingKey.comparisonCount == 1) + #expect(!a.isEquivalent(to: c)) + #expect(CountingKey.comparisonCount == 1) + + } + + @Test func cascading() { + + // Note on ForcedResultKey: + // Environment's equality checks iterate over the keys in its storage dictionary in a nondetermistic order, so we we just populate the dict with + // a variety of keys, some true/false in different contexts. If we simply used CountingKey to observe comparisons, sometimes CountingKey woudln't be + // compared, because the iteration would've already hit a false value earlier in the loop and bailed. Instead, we use ForcedResultKey to simulate this. + + var a = Environment() + a[ForcedResultKey.self] = true + var b = Environment() + b[ForcedResultKey.self] = true + + var expectedCount = 0 + + #expect(expectedCount == ForcedResultKey.comparisonCount) + #expect(a.isEquivalent(to: b, in: .internalElementLayout)) + expectedCount += 1 + #expect(expectedCount == ForcedResultKey.comparisonCount) + + // A specific equivalency being true doesn't imply `.all` to be true, so we should see another evaluation. + a[ForcedResultKey.self] = false + #expect(!a.isEquivalent(to: b, in: .all)) + expectedCount += 1 + #expect(expectedCount == ForcedResultKey.comparisonCount) + + // `.all` equivalency implies that any more fine-grained equivalency should also be true, so we should be using a cached result. + var c = Environment() + c[ForcedResultKey.self] = true + var d = Environment() + d[ForcedResultKey.self] = true + + #expect(c.isEquivalent(to: d, in: .all)) + expectedCount += 1 + #expect(expectedCount == ForcedResultKey.comparisonCount) + #expect(c.isEquivalent(to: d, in: .overallLayout)) + #expect(expectedCount == ForcedResultKey.comparisonCount) + + // A specific equivalency being false implies `.all` to be be false, so we should be using a cached result. + var e = Environment() + e[ForcedResultKey.self] = false + let f = Environment() + + #expect(expectedCount == ForcedResultKey.comparisonCount) + #expect(!e.isEquivalent(to: f, in: .internalElementLayout)) + expectedCount += 1 + #expect(expectedCount == ForcedResultKey.comparisonCount) + #expect(!a.isEquivalent(to: b, in: .all)) + #expect(expectedCount == ForcedResultKey.comparisonCount) + + } + +} + +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([.internalElementLayout], evaluatingContext: context) + } +} + +enum CountingKey: EnvironmentKey { + static let defaultValue = 0 + static var comparisonCount = 0 + + static func isEquivalent(lhs: Int, rhs: Int, in context: EquivalencyContext) -> Bool { + comparisonCount += 1 + return lhs == rhs + } +} + +enum ForcedResultKey: EnvironmentKey { + static let defaultValue: Bool? = nil + static var comparisonCount = 0 + + static func isEquivalent(lhs: Bool?, rhs: Bool?, in context: EquivalencyContext) -> Bool { + comparisonCount += 1 + if let lhs { + return lhs + } + return lhs == rhs + } +} diff --git a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift index 425bf85bc..4116d2698 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift @@ -34,7 +34,7 @@ public struct URLHandlerEnvironmentKey: EnvironmentKey { }() public static func isEquivalent(lhs: any URLHandler, rhs: any URLHandler, in context: EquivalencyContext) -> Bool { - alwaysEquivalentIn([.overallLayout, .internalElementLayout], in: context) + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) } } diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index 7263b81e6..a301d00c2 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -3,7 +3,7 @@ import Foundation import UIKit @_spi(CacheStorage) import BlueprintUI -public struct AttributedLabel: Element, Hashable { +public struct AttributedLabel: Element, Hashable, ContextuallyEquivalent { /// The attributed text to render in the label. /// @@ -133,7 +133,7 @@ public struct AttributedLabel: Element, Hashable { public var content: ElementContent { - ElementContent(cacheKey: self) { constraint, environment -> CGSize in + ElementContent(validationKey: self) { constraint, environment -> CGSize in let text = displayableAttributedText(environment: environment) let label = Self.prototypeLabel label.update(model: self, text: text, environment: environment, isMeasuring: true) diff --git a/SampleApp/Sources/PostsViewController.swift b/SampleApp/Sources/PostsViewController.swift index 08d8292de..b40c428ba 100644 --- a/SampleApp/Sources/PostsViewController.swift +++ b/SampleApp/Sources/PostsViewController.swift @@ -125,7 +125,7 @@ extension Environment { private enum FeedThemeKey: EnvironmentKey { static func isEquivalent(lhs: FeedTheme, rhs: FeedTheme, in context: BlueprintUI.EquivalencyContext) -> Bool { - alwaysEquivalentIn([.internalElementLayout, .overallLayout], in: context) + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) } static let defaultValue = FeedTheme(authorColor: .black) From 398dc4068c7ecc4e9e022f01bc267a9dc9fb4ffb Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Fri, 25 Jul 2025 00:35:30 -0700 Subject: [PATCH 14/34] Merge. --- .../Environment/Cache/CacheStorage.swift | 48 ++++- .../Cache/EnvironmentEntangledCache.swift | 43 ---- .../Environment/Cache/ValidatingCache.swift | 194 ++++++++++++++++++ .../Sources/Environment/Environment.swift | 111 ++++++++-- BlueprintUI/Sources/Internal/Logger.swift | 65 +++++- .../Tests/EnvironmentEquivalencyTests.swift | 172 ++++++++++++++++ 6 files changed, 563 insertions(+), 70 deletions(-) delete mode 100644 BlueprintUI/Sources/Environment/Cache/EnvironmentEntangledCache.swift create mode 100644 BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift create mode 100644 BlueprintUI/Tests/EnvironmentEquivalencyTests.swift diff --git a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift index 27a0cbb39..a0158cecc 100644 --- a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift +++ b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift @@ -1,4 +1,7 @@ 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 @@ -7,7 +10,19 @@ import Foundation // Optional name to distinguish between instances for debugging purposes. public var name: String? = nil - private var storage: [ObjectIdentifier: Any] = [:] + 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 { @@ -19,25 +34,52 @@ import Foundation } public var debugDescription: String { - if let name { + 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[internal: CacheStorageEnvironmentKey.self] } set { self[internal: 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 { + + 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 + } + +} + diff --git a/BlueprintUI/Sources/Environment/Cache/EnvironmentEntangledCache.swift b/BlueprintUI/Sources/Environment/Cache/EnvironmentEntangledCache.swift deleted file mode 100644 index 3e2da853c..000000000 --- a/BlueprintUI/Sources/Environment/Cache/EnvironmentEntangledCache.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation - -/// Simple dictionary-like key value cache with enforcement around environment consistency. -@_spi(CacheStorage) public struct EnvironmentEntangledCache: Sendable { - - private var storage: [Key: (Environment, Value)] = [:] - - public subscript(uncheckedKey key: Key) -> Value? { - storage[key]?.1 - } - - /// Retrieves a value if one exists in the cache for a given environment and equivalency context. - /// - Parameters: - /// - key: The key to look up. - /// - environment: The environment being evaluated. - /// - context: The context in which the environment is being evaluated in. - /// - create: A closure to create a fresh value if no suitable cached value is available. - /// - Returns: A cached or freshly created value. - /// - Note: If no value exists for the key, or a value exists for the key but the environment does not meet the require equivalency, any existing value will be evicted from the cache and a fresh value will be created and stored. - public mutating func retrieveOrCreate( - key: Key, - environment: Environment, - context: EquivalencyContext, - create: () -> Value - ) -> Value { - if let existing = storage[key] { - if existing.0.isEquivalent(to: environment, in: context) { - return existing.1 - } else { - storage.removeValue(forKey: key) - } - } - let fresh = create() - storage[key] = (environment, fresh) - return fresh - } - - public mutating func removeValue(forKey key: Key) -> Value? { - storage.removeValue(forKey: key)?.1 - } - -} - diff --git a/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift new file mode 100644 index 000000000..07a887ec3 --- /dev/null +++ b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift @@ -0,0 +1,194 @@ +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: () -> Value + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isEquivalent(to: $0, in: context) + } create: { + (create(), environment.frozen) + } + } + +} + +/// 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: () -> (Value, AdditionalValidationData) + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isEquivalent(to: $0.0, in: context) && validate($0.1) + } create: { + let (fresh, additional) = create() + return (fresh, (environment.frozen, 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: () -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0.isEquivalent(to: validationValue, in: context) + } create: { + (create(), 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: () -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0 == validationValue + } create: { + (create(), 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 6fae5ae06..d7009ab8d 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -40,16 +40,12 @@ public struct Environment { /// Each key will return its default value. public static let empty = Environment() - private var values: [Keybox: Any] = [:] { - didSet { - fingerprint = UUID() - } - } + // Fingerprint used for referencing previously compared environments. + private var fingerprint = ComparableFingerprint() - private var internalValues: [ObjectIdentifier: Any] = [:] + private var values: [Keybox: Any] = [:] - // Fingerprint used for referencing previously compared environments. - fileprivate var fingerprint: UUID = UUID() + private var internalValues: [ObjectIdentifier: Any] = [:] /// Gets or sets an environment value by its key. public subscript(key: Key.Type) -> Key.Value where Key: EnvironmentKey { @@ -57,7 +53,12 @@ public struct Environment { self[Keybox(key)] as! Key.Value } set { - values[Keybox(key)] = newValue + let keybox = Keybox(key) + let oldValue = values[keybox] + values[keybox] = newValue + if !keybox.isEquivalent(newValue, oldValue, .all) { + fingerprint.modified() + } } } @@ -67,7 +68,7 @@ public struct Environment { public subscript(internal key: Key.Type) -> Key.Value where Key: EnvironmentKey { get { - internalValues[ObjectIdentifier(key)] as! Key.Value + internalValues[ObjectIdentifier(key), default: key.defaultValue] as! Key.Value } set { internalValues[ObjectIdentifier(key)] = newValue @@ -85,40 +86,110 @@ public struct Environment { func merged(prioritizing other: Environment) -> Environment { var merged = self merged.values.merge(other.values) { $1 } + merged.fingerprint.modified() return merged } + + var frozen: FrozenEnvironment { + FrozenEnvironment(fingerprint: fingerprint, values: values) + } + + func thawing(frozen: FrozenEnvironment) -> Environment { + var merged = self + merged.values = frozen.values + merged.fingerprint = frozen.fingerprint + return merged + } + + +} + +/// A frozen environment is immutable copy of the comparable elements of an Environment struct. +struct FrozenEnvironment { + + // Fingerprint used for referencing previously compared environments. + let fingerprint: ComparableFingerprint + let values: [Environment.Keybox: Any] + } extension Environment: ContextuallyEquivalent { - public func isEquivalent(to other: Environment?, in context: EquivalencyContext) -> Bool { + public func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { guard let other else { return false } - if fingerprint == other.fingerprint { return true } - if let evaluated = cacheStorage.environmentComparisonCache[other.fingerprint] ?? other.cacheStorage.environmentComparisonCache[fingerprint], let result = evaluated[context] { - return result + if fingerprint.isEquivalent(to: other.fingerprint) { return true } + if let evaluated = cacheStorage.environmentComparisonCache[other.fingerprint, context] ?? other.cacheStorage.environmentComparisonCache[ + fingerprint, + context + ] { + return evaluated } let keys = Set(values.keys).union(other.values.keys) for key in keys { guard key.isEquivalent(self[key], other[key], context) else { - cacheStorage.environmentComparisonCache[other.fingerprint, default: [:]][context] = false + cacheStorage.environmentComparisonCache[other.fingerprint, context] = false return false } } - cacheStorage.environmentComparisonCache[other.fingerprint, default: [:]][context] = true + cacheStorage.environmentComparisonCache[other.fingerprint, context] = true return true } -} + func isEquivalent(to other: FrozenEnvironment?, in context: EquivalencyContext) -> Bool { + guard let other else { return false } + // We don't even need to thaw the environment if the fingerprints match. + if frozen.fingerprint.isEquivalent(to: fingerprint) { return true } + return isEquivalent(to: thawing(frozen: frozen), in: context) + } +} + extension CacheStorage { + fileprivate struct EnvironmentFingerprintCache { + + typealias EquivalencyResult = [EquivalencyContext: Bool] + var storage: [ComparableFingerprint.Value: [EquivalencyContext: Bool]] = [:] + + public subscript(fingerprint: ComparableFingerprint, context: EquivalencyContext) -> Bool? { + get { + if let exact = storage[fingerprint.value]?[context] { + return exact + } else if let allComparisons = storage[fingerprint.value] { + 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[fingerprint.value, default: [:]][context] = newValue + } + } + + } + /// A cache of previously compared environments and their results. private struct EnvironmentComparisonCacheKey: CacheKey { - static var emptyValue: [UUID: [EquivalencyContext: Bool]] = [:] + static var emptyValue = EnvironmentFingerprintCache() } - fileprivate var environmentComparisonCache: [UUID: [EquivalencyContext: Bool]] { + fileprivate var environmentComparisonCache: EnvironmentFingerprintCache { get { self[EnvironmentComparisonCacheKey.self] } set { self[EnvironmentComparisonCacheKey.self] = newValue } } @@ -128,7 +199,7 @@ extension CacheStorage { extension Environment { /// Lightweight key type eraser. - fileprivate struct Keybox: Hashable, CustomStringConvertible { + struct Keybox: Hashable, CustomStringConvertible { let objectIdentifier: ObjectIdentifier let type: any EnvironmentKey.Type diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index b2074465a..b50f2cb40 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -2,9 +2,11 @@ import Foundation import os.log /// Namespace for logging helpers -enum Logger {} +enum Logger { + fileprivate static let signposter = OSSignposter(logHandle: .active) +} -/// BlueprintView signposts +// MARK: - BlueprintView signposts extension Logger { static func logLayoutStart(view: BlueprintView) { @@ -100,7 +102,8 @@ extension Logger { } } -/// Measuring signposts +// MARK: - HintingSizeCache signposts + extension Logger { static func logMeasureStart(object: AnyObject, description: String, constraint: SizeConstraint) { @@ -185,10 +188,64 @@ extension Logger { ) } - // MARK: Utilities + +} + +// MARK: - CacheStorage + +extension Logger { + + // MARK: ValidatingCache + + static func logValidatingCacheValidationStart(key: some Hashable) -> OSSignpostIntervalState { + signposter.beginInterval("ValidatingCache validation", id: key.signpost, "Start: \(String(describing: key))") + } + + static func logValidatingCacheValidationEnd(_ token: OSSignpostIntervalState, key: some Hashable) { + signposter.endInterval("ValidatingCache validation", token, "\(String(describing: key))") + } + + static func logValidatingCacheFreshValueCreationStart(key: some Hashable) -> OSSignpostIntervalState { + signposter.beginInterval("ValidatingCache fresh value creation", id: key.signpost, "\(String(describing: key))") + } + + static func logValidatingCacheFreshValueCreationEnd(_ token: OSSignpostIntervalState, key: some Hashable) { + signposter.endInterval("ValidatingCache fresh value creation", token, "\(String(describing: key))") + } + + static func logValidatingCacheKeyMiss(key: some Hashable) { + signposter.emitEvent("ValidatingCache key miss", id: key.signpost, "\(String(describing: key))") + } + + static func logValidatingCacheKeyHit(key: some Hashable) { + signposter.emitEvent("ValidatingCache key hit", id: key.signpost, "\(String(describing: key))") + } + + static func logValidatingCacheHitAndValidationSuccess(key: some Hashable) { + signposter.emitEvent("ValidatingCache validation success", id: key.signpost, "\(String(describing: key))") + } + + static func logValidatingCacheHitAndValidationFailure(key: some Hashable) { + signposter.emitEvent("ValidatingCache validation failure", id: key.signpost, "\(String(describing: key))") + } + +} + +extension Hashable { + + 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..c1222c760 --- /dev/null +++ b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift @@ -0,0 +1,172 @@ +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() { + var a = Environment() + let b = a + a[CountingKey.self] = 1 + #expect(CountingKey.comparisonCount == 0) + #expect(!a.isEquivalent(to: b)) + // First comparison should call comparison method + #expect(CountingKey.comparisonCount == 1) + + #expect(!a.isEquivalent(to: b)) + // Subsequent comparison should be cached + #expect(CountingKey.comparisonCount == 1) + + #expect(!b.isEquivalent(to: a)) + // Reversed order should still be cached + #expect(CountingKey.comparisonCount == 1) + + // Copying without mutation should preserve fingerprint, and be cached. + let c = b + #expect(CountingKey.comparisonCount == 1) + #expect(!a.isEquivalent(to: c)) + #expect(CountingKey.comparisonCount == 1) + + } + + @Test func cascading() { + + // Note on ForcedResultKey: + // Environment's equality checks iterate over the keys in its storage dictionary in a nondetermistic order, so we we just populate the dict with + // a variety of keys, some true/false in different contexts. If we simply used CountingKey to observe comparisons, sometimes CountingKey woudln't be + // compared, because the iteration would've already hit a false value earlier in the loop and bailed. Instead, we use ForcedResultKey to simulate this. + + var a = Environment() + a[ForcedResultKey.self] = true + var b = Environment() + b[ForcedResultKey.self] = true + + var expectedCount = 0 + + #expect(expectedCount == ForcedResultKey.comparisonCount) + #expect(a.isEquivalent(to: b, in: .internalElementLayout)) + expectedCount += 1 + #expect(expectedCount == ForcedResultKey.comparisonCount) + + // A specific equivalency being true doesn't imply `.all` to be true, so we should see another evaluation. + a[ForcedResultKey.self] = false + #expect(!a.isEquivalent(to: b, in: .all)) + expectedCount += 1 + #expect(expectedCount == ForcedResultKey.comparisonCount) + + // `.all` equivalency implies that any more fine-grained equivalency should also be true, so we should be using a cached result. + var c = Environment() + c[ForcedResultKey.self] = true + var d = Environment() + d[ForcedResultKey.self] = true + + #expect(c.isEquivalent(to: d, in: .all)) + expectedCount += 1 + #expect(expectedCount == ForcedResultKey.comparisonCount) + #expect(c.isEquivalent(to: d, in: .overallLayout)) + #expect(expectedCount == ForcedResultKey.comparisonCount) + + // A specific equivalency being false implies `.all` to be be false, so we should be using a cached result. + var e = Environment() + e[ForcedResultKey.self] = false + let f = Environment() + + #expect(expectedCount == ForcedResultKey.comparisonCount) + #expect(!e.isEquivalent(to: f, in: .internalElementLayout)) + expectedCount += 1 + #expect(expectedCount == ForcedResultKey.comparisonCount) + #expect(!a.isEquivalent(to: b, in: .all)) + #expect(expectedCount == ForcedResultKey.comparisonCount) + + } + +} + +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([.internalElementLayout], evaluatingContext: context) + } +} + +enum CountingKey: EnvironmentKey { + static let defaultValue = 0 + static var comparisonCount = 0 + + static func isEquivalent(lhs: Int, rhs: Int, in context: EquivalencyContext) -> Bool { + comparisonCount += 1 + return lhs == rhs + } +} + +enum ForcedResultKey: EnvironmentKey { + static let defaultValue: Bool? = nil + static var comparisonCount = 0 + + static func isEquivalent(lhs: Bool?, rhs: Bool?, in context: EquivalencyContext) -> Bool { + comparisonCount += 1 + if let lhs { + return lhs + } + return lhs == rhs + } +} From be9dd21861ce4a2f79f597e1ab63346ec2aab107 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Fri, 25 Jul 2025 00:38:03 -0700 Subject: [PATCH 15/34] Merge --- .../Sources/Element/ElementContent.swift | 15 +++++-- .../Sources/Element/MeasurableStorage.swift | 44 ++++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/BlueprintUI/Sources/Element/ElementContent.swift b/BlueprintUI/Sources/Element/ElementContent.swift index 7563a6cc3..396483808 100644 --- a/BlueprintUI/Sources/Element/ElementContent.swift +++ b/BlueprintUI/Sources/Element/ElementContent.swift @@ -242,13 +242,22 @@ extension ElementContent { /// Initializes a new `ElementContent` with no children that delegates to the provided measure function. /// - /// - parameter cacheKey: If present, a key with which the measureFunction result will be cached. The key will be hashed and discarded to avoid memory bloat. /// - parameter measureFunction: How to measure the `ElementContent` in the given `SizeConstraint` and `Environment`. public init( - cacheKey: AnyHashable? = nil, measureFunction: @escaping (SizeConstraint, Environment) -> CGSize ) { - storage = MeasurableStorage(cacheKey: cacheKey, measurer: measureFunction) + storage = MeasurableStorage(measurer: measureFunction) + } + + /// Initializes a new `ElementContent` with no children that delegates to the provided measure function. + /// + /// - parameter validationKey: If present, measureFunction will attempt to cache sizing based on the path of the node. validationKey will be evaluated to ensure that the result is valid. + /// - parameter measureFunction: How to measure the `ElementContent` in the given `SizeConstraint` and `Environment`. + public init( + validationKey: some ContextuallyEquivalent, + measureFunction: @escaping (SizeConstraint, Environment) -> CGSize + ) { + storage = MeasurableStorage(validationKey: validationKey, measurer: measureFunction) } /// Initializes a new `ElementContent` with no children that uses the provided intrinsic size for measuring. diff --git a/BlueprintUI/Sources/Element/MeasurableStorage.swift b/BlueprintUI/Sources/Element/MeasurableStorage.swift index 5eb48a5df..88432b688 100644 --- a/BlueprintUI/Sources/Element/MeasurableStorage.swift +++ b/BlueprintUI/Sources/Element/MeasurableStorage.swift @@ -7,8 +7,18 @@ struct MeasurableStorage: ContentStorage { let childCount = 0 - let cacheKey: AnyHashable? + let validationKey: AnyContextuallyEquivalent? let measurer: (SizeConstraint, Environment) -> CGSize + + init(validationKey: some ContextuallyEquivalent, measurer: @escaping (SizeConstraint, Environment) -> CGSize) { + self.validationKey = AnyContextuallyEquivalent(validationKey) + self.measurer = measurer + } + + init(measurer: @escaping (SizeConstraint, Environment) -> CGSize) { + validationKey = nil + self.measurer = measurer + } } extension MeasurableStorage: CaffeinatedContentStorage { @@ -18,14 +28,16 @@ extension MeasurableStorage: CaffeinatedContentStorage { environment: Environment, node: LayoutTreeNode ) -> CGSize { - guard environment.layoutMode.options.measureableStorageCache, let cacheKey else { + guard environment.layoutMode.options.measureableStorageCache, let validationKey else { return measurer(proposal, environment) } - let key = MeasurableSizeKey(model: cacheKey, max: proposal.maximum) + + let key = MeasurableSizeKey(path: node.path, max: proposal.maximum) return environment.cacheStorage.measurableStorageCache.retrieveOrCreate( key: key, environment: environment, - context: .internalElementLayout + validationValue: validationKey, + context: .elementSizing, ) { measurer(proposal, environment) } @@ -43,13 +55,15 @@ extension MeasurableStorage: CaffeinatedContentStorage { extension MeasurableStorage { fileprivate struct MeasurableSizeKey: Hashable { - let hashValue: Int - init(model: AnyHashable, max: CGSize) { - var hasher = Hasher() - model.hash(into: &hasher) + + let path: String + let max: CGSize + + func hash(into hasher: inout Hasher) { + path.hash(into: &hasher) max.hash(into: &hasher) - hashValue = hasher.finalize() } + } } @@ -57,10 +71,18 @@ extension MeasurableStorage { extension CacheStorage { private struct MeasurableStorageCacheKey: CacheKey { - static var emptyValue = EnvironmentEntangledCache() + static var emptyValue = EnvironmentAndValueValidatingCache< + MeasurableStorage.MeasurableSizeKey, + CGSize, + AnyContextuallyEquivalent + >() } - fileprivate var measurableStorageCache: EnvironmentEntangledCache { + fileprivate var measurableStorageCache: EnvironmentAndValueValidatingCache< + MeasurableStorage.MeasurableSizeKey, + CGSize, + AnyContextuallyEquivalent + > { get { self[MeasurableStorageCacheKey.self] } set { self[MeasurableStorageCacheKey.self] = newValue } } From 942c1fa52557a116b7222006abcfe86d795167fe Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Fri, 25 Jul 2025 14:06:20 -0700 Subject: [PATCH 16/34] Tests and fixes --- .../Sources/Environment/Environment.swift | 27 ++++- BlueprintUI/Sources/Internal/Logger.swift | 62 ++++++++++- BlueprintUI/Tests/ValidatingCacheTests.swift | 101 ++++++++++++++++++ 3 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 BlueprintUI/Tests/ValidatingCacheTests.swift diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index d7009ab8d..f88e05b41 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -41,7 +41,7 @@ public struct Environment { public static let empty = Environment() // Fingerprint used for referencing previously compared environments. - private var fingerprint = ComparableFingerprint() + var fingerprint = ComparableFingerprint() private var values: [Keybox: Any] = [:] @@ -56,9 +56,11 @@ public struct Environment { let keybox = Keybox(key) let oldValue = values[keybox] values[keybox] = newValue + let token = Logger.logEnvironmentKeySetEquivalencyComparisonStart(key: keybox) if !keybox.isEquivalent(newValue, oldValue, .all) { fingerprint.modified() } + Logger.logEnvironmentKeySetEquivalencyComparisonEnd(token, key: keybox) } } @@ -117,20 +119,34 @@ 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) { return true } + if fingerprint.isEquivalent(to: other.fingerprint) { + Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) + return true + } if let evaluated = cacheStorage.environmentComparisonCache[other.fingerprint, context] ?? other.cacheStorage.environmentComparisonCache[ 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[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[other.fingerprint, context] = true return true } @@ -138,8 +154,11 @@ extension Environment: ContextuallyEquivalent { func isEquivalent(to other: FrozenEnvironment?, in context: EquivalencyContext) -> Bool { guard let other else { return false } // We don't even need to thaw the environment if the fingerprints match. - if frozen.fingerprint.isEquivalent(to: fingerprint) { return true } - return isEquivalent(to: thawing(frozen: frozen), in: context) + if other.fingerprint.isEquivalent(to: fingerprint) { + Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) + return true + } + return isEquivalent(to: thawing(frozen: other), in: context) } diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index b50f2cb40..88ef98350 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -196,6 +196,66 @@ extension Logger { extension Logger { + // MARK: Environment Comparison + + static func logEnvironmentKeySetEquivalencyComparisonStart(key: some Hashable) -> OSSignpostIntervalState { + signposter.beginInterval( + "Environment key set equivalency comparison", + id: key.signpost, + "Start: \(String(describing: key))" + ) + } + + static func logEnvironmentKeySetEquivalencyComparisonEnd(_ token: OSSignpostIntervalState, key: some Hashable) { + signposter.endInterval("Environment key set equivalency comparison", token, "\(String(describing: key))") + } + + static func logEnvironmentEquivalencyComparisonStart(environment: Environment) -> OSSignpostIntervalState { + signposter.beginInterval( + "Environment equivalency comparison", + id: environment.fingerprint.value.signpost, + "Start: \(String(describing: environment))" + ) + } + + static func logEnvironmentEquivalencyComparisonEnd(_ token: OSSignpostIntervalState, environment: Environment) { + signposter.endInterval("Environment equivalency comparison", token, "\(String(describing: environment))") + } + + static func logEnvironmentEquivalencyFingerprintEqual(environment: Environment) { + signposter.emitEvent("Environments trivially equal from fingerprint", id: environment.fingerprint.value.signpost) + } + + static func logEnvironmentEquivalencyFingerprintCacheHit(environment: Environment) { + signposter.emitEvent("Environment cached comparison result hit", id: environment.fingerprint.value.signpost) + } + + static func logEnvironmentEquivalencyFingerprintCacheMiss(environment: Environment) { + signposter.emitEvent("Environment cached comparison result miss", id: environment.fingerprint.value.signpost) + } + + static func logEnvironmentEquivalencyCompletedWithNonEquivalence( + environment: Environment, + key: some Hashable, + context: EquivalencyContext + ) { + signposter.emitEvent( + "Environment equivalency completed with non-equivalent result", + id: environment.fingerprint.value.signpost, + "\(String(describing: context)): \(String(describing: key)) not equivalent" + ) + } + + static func logEnvironmentEquivalencyCompletedWithEquivalence(environment: Environment, context: EquivalencyContext) { + signposter.emitEvent( + "Environment equivalency completed with equivalent result", + id: environment.fingerprint.value.signpost, + "\(String(describing: context))" + ) + + } + + // MARK: ValidatingCache static func logValidatingCacheValidationStart(key: some Hashable) -> OSSignpostIntervalState { @@ -234,7 +294,7 @@ extension Logger { extension Hashable { - var signpost: OSSignpostID { + fileprivate var signpost: OSSignpostID { OSSignpostID(UInt64(abs(hashValue))) } diff --git a/BlueprintUI/Tests/ValidatingCacheTests.swift b/BlueprintUI/Tests/ValidatingCacheTests.swift new file mode 100644 index 000000000..59b19955b --- /dev/null +++ b/BlueprintUI/Tests/ValidatingCacheTests.swift @@ -0,0 +1,101 @@ +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) + } + +} + +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) { + "One" + } + #expect(one == "One") + + let two = cache.retrieveOrCreate(key: "Hello", environment: environment, context: .all) { + "Two" + } + #expect(two == "One") + + let three = cache.retrieveOrCreate(key: "KeyMiss", environment: environment, context: .all) { + "Three" + } + #expect(three == "Three") + + var differentEnvironment = environment + differentEnvironment[ExampleKey.self] = 2 + let four = cache.retrieveOrCreate(key: "Hello", environment: differentEnvironment, context: .all) { + "Four" + } + #expect(four == "Four") + } + +} From 65194186a980ce62383aa68fe46c48452d90a88a Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Fri, 25 Jul 2025 14:11:56 -0700 Subject: [PATCH 17/34] More tests --- BlueprintUI/Tests/ValidatingCacheTests.swift | 62 ++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/BlueprintUI/Tests/ValidatingCacheTests.swift b/BlueprintUI/Tests/ValidatingCacheTests.swift index 59b19955b..388001e28 100644 --- a/BlueprintUI/Tests/ValidatingCacheTests.swift +++ b/BlueprintUI/Tests/ValidatingCacheTests.swift @@ -99,3 +99,65 @@ struct EnvironmentValidatingCacheTests { } } + + +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 + ) { + "One" + } + #expect(one == "One") + + let two = cache.retrieveOrCreate( + key: "Hello", + environment: environment, + validationValue: "Validate", + context: .all + ) { + "Two" + } + #expect(two == "One") + + let three = cache.retrieveOrCreate( + key: "KeyMiss", + environment: environment, + validationValue: "Validate", + context: .all + ) { + "Three" + } + #expect(three == "Three") + + var differentEnvironment = environment + differentEnvironment[ExampleKey.self] = 2 + let four = cache.retrieveOrCreate( + key: "Hello", + environment: differentEnvironment, + validationValue: "Validate", + context: .all + ) { + "Four" + } + #expect(four == "Four") + + let five = cache.retrieveOrCreate( + key: "Hello", + environment: differentEnvironment, + validationValue: "Invalid", + context: .all + ) { + "Five" + } + #expect(five == "Five") + } + +} From afbbc3c24037bb1c873b8aea9b8b23ba7ef98ed7 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Fri, 25 Jul 2025 14:46:25 -0700 Subject: [PATCH 18/34] More tests --- .../Tests/EnvironmentEquivalencyTests.swift | 8 +- BlueprintUI/Tests/ValidatingCacheTests.swift | 121 ++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift index c1222c760..e468e7aae 100644 --- a/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift +++ b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift @@ -94,7 +94,7 @@ struct EnvironmentEquivalencyTests { var expectedCount = 0 #expect(expectedCount == ForcedResultKey.comparisonCount) - #expect(a.isEquivalent(to: b, in: .internalElementLayout)) + #expect(a.isEquivalent(to: b, in: .elementSizing)) expectedCount += 1 #expect(expectedCount == ForcedResultKey.comparisonCount) @@ -113,7 +113,7 @@ struct EnvironmentEquivalencyTests { #expect(c.isEquivalent(to: d, in: .all)) expectedCount += 1 #expect(expectedCount == ForcedResultKey.comparisonCount) - #expect(c.isEquivalent(to: d, in: .overallLayout)) + #expect(c.isEquivalent(to: d, in: .elementSizing)) #expect(expectedCount == ForcedResultKey.comparisonCount) // A specific equivalency being false implies `.all` to be be false, so we should be using a cached result. @@ -122,7 +122,7 @@ struct EnvironmentEquivalencyTests { let f = Environment() #expect(expectedCount == ForcedResultKey.comparisonCount) - #expect(!e.isEquivalent(to: f, in: .internalElementLayout)) + #expect(!e.isEquivalent(to: f, in: .elementSizing)) expectedCount += 1 #expect(expectedCount == ForcedResultKey.comparisonCount) #expect(!a.isEquivalent(to: b, in: .all)) @@ -144,7 +144,7 @@ enum NonSizeAffectingKey: EnvironmentKey { static let defaultValue = 0 static func isEquivalent(lhs: Int, rhs: Int, in context: EquivalencyContext) -> Bool { - alwaysEquivalentIn([.internalElementLayout], evaluatingContext: context) + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) } } diff --git a/BlueprintUI/Tests/ValidatingCacheTests.swift b/BlueprintUI/Tests/ValidatingCacheTests.swift index 388001e28..bf8d18c88 100644 --- a/BlueprintUI/Tests/ValidatingCacheTests.swift +++ b/BlueprintUI/Tests/ValidatingCacheTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @_spi(CacheStorage) @testable import BlueprintUI @@ -69,6 +70,7 @@ struct ValidatingCacheTests { } +@MainActor struct EnvironmentValidatingCacheTests { @Test func basic() { @@ -101,6 +103,7 @@ struct EnvironmentValidatingCacheTests { } +@MainActor struct EnvironmentAndValueValidatingCacheTests { @Test func basic() { @@ -160,4 +163,122 @@ struct EnvironmentAndValueValidatingCacheTests { #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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 f4c5c2e30e865a8518608183e0732673318cb8ec Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Mon, 28 Jul 2025 11:25:37 -0700 Subject: [PATCH 19/34] Tweak env tests --- .../Environment/Cache/CacheStorage.swift | 6 +- BlueprintUI/Sources/Internal/Logger.swift | 40 ++++++- .../Tests/EnvironmentEquivalencyTests.swift | 112 ++++++++---------- 3 files changed, 91 insertions(+), 67 deletions(-) diff --git a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift index a0158cecc..9a3b91365 100644 --- a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift +++ b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift @@ -62,7 +62,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: ContextuallyEquivalent { +struct ComparableFingerprint: ContextuallyEquivalent, CustomStringConvertible { typealias Value = UUID @@ -81,5 +81,9 @@ struct ComparableFingerprint: ContextuallyEquivalent { value == other?.value } + var description: String { + value.uuidString + } + } diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index 88ef98350..235c66921 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -4,12 +4,14 @@ import os.log /// Namespace for logging helpers enum Logger { fileprivate static let signposter = OSSignposter(logHandle: .active) + static var hook: ((String) -> Void)? } // MARK: - BlueprintView signposts extension Logger { static func logLayoutStart(view: BlueprintView) { + guard BlueprintLogging.isEnabled else { return } os_signpost( @@ -199,39 +201,48 @@ extension Logger { // MARK: Environment Comparison static func logEnvironmentKeySetEquivalencyComparisonStart(key: some Hashable) -> OSSignpostIntervalState { - signposter.beginInterval( + 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) { signposter.endInterval("Environment key set equivalency comparison", token, "\(String(describing: key))") + hook?("\(#function) \(String(describing: key))") } static func logEnvironmentEquivalencyComparisonStart(environment: Environment) -> OSSignpostIntervalState { - signposter.beginInterval( + 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) { signposter.endInterval("Environment equivalency comparison", token, "\(String(describing: environment))") + hook?("\(#function) \(environment.fingerprint)") } static func logEnvironmentEquivalencyFingerprintEqual(environment: Environment) { signposter.emitEvent("Environments trivially equal from fingerprint", id: environment.fingerprint.value.signpost) + hook?("\(#function) \(environment.fingerprint)") } static func logEnvironmentEquivalencyFingerprintCacheHit(environment: Environment) { signposter.emitEvent("Environment cached comparison result hit", id: environment.fingerprint.value.signpost) + hook?("\(#function) \(environment.fingerprint)") } static func logEnvironmentEquivalencyFingerprintCacheMiss(environment: Environment) { signposter.emitEvent("Environment cached comparison result miss", id: environment.fingerprint.value.signpost) + hook?("\(#function) \(environment.fingerprint)") } static func logEnvironmentEquivalencyCompletedWithNonEquivalence( @@ -244,6 +255,7 @@ extension Logger { 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) { @@ -252,42 +264,60 @@ extension Logger { id: environment.fingerprint.value.signpost, "\(String(describing: context))" ) - + hook?("\(#function) \(environment.fingerprint)") } // MARK: ValidatingCache static func logValidatingCacheValidationStart(key: some Hashable) -> OSSignpostIntervalState { - signposter.beginInterval("ValidatingCache validation", id: key.signpost, "Start: \(String(describing: key))") + 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) { signposter.endInterval("ValidatingCache validation", token, "\(String(describing: key))") + hook?("\(#function) \(String(describing: key))") } static func logValidatingCacheFreshValueCreationStart(key: some Hashable) -> OSSignpostIntervalState { - signposter.beginInterval("ValidatingCache fresh value creation", id: key.signpost, "\(String(describing: key))") + 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) { signposter.endInterval("ValidatingCache fresh value creation", token, "\(String(describing: key))") + hook?("\(#function) \(String(describing: key))") } static func logValidatingCacheKeyMiss(key: some Hashable) { signposter.emitEvent("ValidatingCache key miss", id: key.signpost, "\(String(describing: key))") + hook?("\(#function) \(String(describing: key))") } static func logValidatingCacheKeyHit(key: some Hashable) { signposter.emitEvent("ValidatingCache key hit", id: key.signpost, "\(String(describing: key))") + hook?("\(#function) \(String(describing: key))") } static func logValidatingCacheHitAndValidationSuccess(key: some Hashable) { signposter.emitEvent("ValidatingCache validation success", id: key.signpost, "\(String(describing: key))") + hook?("\(#function) \(String(describing: key))") } static func logValidatingCacheHitAndValidationFailure(key: some Hashable) { signposter.emitEvent("ValidatingCache validation failure", id: key.signpost, "\(String(describing: key))") + hook?("\(#function) \(String(describing: key))") } } diff --git a/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift index e468e7aae..340afe388 100644 --- a/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift +++ b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift @@ -55,79 +55,92 @@ struct EnvironmentEquivalencyTests { } @Test func caching() { + var hookedResult: [String] = [] + Logger.hook = { + hookedResult.append($0) + } var a = Environment() let b = a - a[CountingKey.self] = 1 - #expect(CountingKey.comparisonCount == 0) + a[ExampleKey.self] = 1 + hookedResult = [] #expect(!a.isEquivalent(to: b)) - // First comparison should call comparison method - #expect(CountingKey.comparisonCount == 1) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) + hookedResult = [] #expect(!a.isEquivalent(to: b)) // Subsequent comparison should be cached - #expect(CountingKey.comparisonCount == 1) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(a.fingerprint)")) + hookedResult = [] #expect(!b.isEquivalent(to: a)) // Reversed order should still be cached - #expect(CountingKey.comparisonCount == 1) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(b.fingerprint)")) - // Copying without mutation should preserve fingerprint, and be cached. + hookedResult = [] let c = b - #expect(CountingKey.comparisonCount == 1) #expect(!a.isEquivalent(to: c)) - #expect(CountingKey.comparisonCount == 1) + // Copying without mutation should preserve fingerprint, and be cached. + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(a.fingerprint)")) } @Test func cascading() { - - // Note on ForcedResultKey: - // Environment's equality checks iterate over the keys in its storage dictionary in a nondetermistic order, so we we just populate the dict with - // a variety of keys, some true/false in different contexts. If we simply used CountingKey to observe comparisons, sometimes CountingKey woudln't be - // compared, because the iteration would've already hit a false value earlier in the loop and bailed. Instead, we use ForcedResultKey to simulate this. - + var hookedResult: [String] = [] + Logger.hook = { + hookedResult.append($0) + } var a = Environment() - a[ForcedResultKey.self] = true + a[ExampleKey.self] = 1 + a[NonSizeAffectingKey.self] = 1 var b = Environment() - b[ForcedResultKey.self] = true + b[ExampleKey.self] = 1 + b[NonSizeAffectingKey.self] = 2 - var expectedCount = 0 - - #expect(expectedCount == ForcedResultKey.comparisonCount) + hookedResult = [] #expect(a.isEquivalent(to: b, in: .elementSizing)) - expectedCount += 1 - #expect(expectedCount == ForcedResultKey.comparisonCount) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) - // A specific equivalency being true doesn't imply `.all` to be true, so we should see another evaluation. - a[ForcedResultKey.self] = false + hookedResult = [] #expect(!a.isEquivalent(to: b, in: .all)) - expectedCount += 1 - #expect(expectedCount == ForcedResultKey.comparisonCount) + // 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)")) - // `.all` equivalency implies that any more fine-grained equivalency should also be true, so we should be using a cached result. var c = Environment() - c[ForcedResultKey.self] = true + c[ExampleKey.self] = 1 var d = Environment() - d[ForcedResultKey.self] = true + d[ExampleKey.self] = 1 + hookedResult = [] #expect(c.isEquivalent(to: d, in: .all)) - expectedCount += 1 - #expect(expectedCount == ForcedResultKey.comparisonCount) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(c.fingerprint)")) + + hookedResult = [] #expect(c.isEquivalent(to: d, in: .elementSizing)) - #expect(expectedCount == ForcedResultKey.comparisonCount) + // `.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[ForcedResultKey.self] = false + e[ExampleKey.self] = 2 let f = Environment() - #expect(expectedCount == ForcedResultKey.comparisonCount) + hookedResult = [] #expect(!e.isEquivalent(to: f, in: .elementSizing)) - expectedCount += 1 - #expect(expectedCount == ForcedResultKey.comparisonCount) - #expect(!a.isEquivalent(to: b, in: .all)) - #expect(expectedCount == ForcedResultKey.comparisonCount) + #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)) } } @@ -147,26 +160,3 @@ enum NonSizeAffectingKey: EnvironmentKey { alwaysEquivalentIn([.elementSizing], evaluatingContext: context) } } - -enum CountingKey: EnvironmentKey { - static let defaultValue = 0 - static var comparisonCount = 0 - - static func isEquivalent(lhs: Int, rhs: Int, in context: EquivalencyContext) -> Bool { - comparisonCount += 1 - return lhs == rhs - } -} - -enum ForcedResultKey: EnvironmentKey { - static let defaultValue: Bool? = nil - static var comparisonCount = 0 - - static func isEquivalent(lhs: Bool?, rhs: Bool?, in context: EquivalencyContext) -> Bool { - comparisonCount += 1 - if let lhs { - return lhs - } - return lhs == rhs - } -} From 9c0da9ffa76a6cfe8550b1603b7edb0f0830b10b Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Mon, 28 Jul 2025 11:55:35 -0700 Subject: [PATCH 20/34] Existentials for Xcode 15 --- BlueprintUI/Sources/Internal/Equivalency.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) } } From 707cc8d04df7eab4d28c809fa8bcb1783e42a7ee Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Tue, 29 Jul 2025 22:24:32 -0700 Subject: [PATCH 21/34] Log guard --- BlueprintUI/Sources/Internal/Logger.swift | 43 ++++++++++++++++------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index 235c66921..a05acfee9 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -200,7 +200,8 @@ extension Logger { // MARK: Environment Comparison - static func logEnvironmentKeySetEquivalencyComparisonStart(key: some Hashable) -> OSSignpostIntervalState { + 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, @@ -210,12 +211,14 @@ extension Logger { return token } - static func logEnvironmentKeySetEquivalencyComparisonEnd(_ token: OSSignpostIntervalState, key: some Hashable) { + 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 { + static func logEnvironmentEquivalencyComparisonStart(environment: Environment) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } let token = signposter.beginInterval( "Environment equivalency comparison", id: environment.fingerprint.value.signpost, @@ -225,22 +228,26 @@ extension Logger { return token } - static func logEnvironmentEquivalencyComparisonEnd(_ token: OSSignpostIntervalState, environment: Environment) { + 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)") } @@ -250,6 +257,7 @@ extension Logger { key: some Hashable, context: EquivalencyContext ) { + guard BlueprintLogging.isEnabled else { return } signposter.emitEvent( "Environment equivalency completed with non-equivalent result", id: environment.fingerprint.value.signpost, @@ -259,6 +267,7 @@ extension Logger { } 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, @@ -270,7 +279,8 @@ extension Logger { // MARK: ValidatingCache - static func logValidatingCacheValidationStart(key: some Hashable) -> OSSignpostIntervalState { + static func logValidatingCacheValidationStart(key: some Hashable) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } let token = signposter.beginInterval( "ValidatingCache validation", id: key.signpost, @@ -280,12 +290,14 @@ extension Logger { return token } - static func logValidatingCacheValidationEnd(_ token: OSSignpostIntervalState, key: some Hashable) { + 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 { + static func logValidatingCacheFreshValueCreationStart(key: some Hashable) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } let token = signposter.beginInterval( "ValidatingCache fresh value creation", id: key.signpost, @@ -295,28 +307,33 @@ extension Logger { return token } - static func logValidatingCacheFreshValueCreationEnd(_ token: OSSignpostIntervalState, key: some Hashable) { - signposter.endInterval("ValidatingCache fresh value creation", token, "\(String(describing: key))") + 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) { - signposter.emitEvent("ValidatingCache key miss", id: key.signpost, "\(String(describing: key))") + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache key miss", id: key.signpost) hook?("\(#function) \(String(describing: key))") } static func logValidatingCacheKeyHit(key: some Hashable) { - signposter.emitEvent("ValidatingCache key hit", id: key.signpost, "\(String(describing: key))") + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache key hit", id: key.signpost) hook?("\(#function) \(String(describing: key))") } static func logValidatingCacheHitAndValidationSuccess(key: some Hashable) { - signposter.emitEvent("ValidatingCache validation success", id: key.signpost, "\(String(describing: key))") + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache validation success", id: key.signpost) hook?("\(#function) \(String(describing: key))") } static func logValidatingCacheHitAndValidationFailure(key: some Hashable) { - signposter.emitEvent("ValidatingCache validation failure", id: key.signpost, "\(String(describing: key))") + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache validation failure", id: key.signpost) hook?("\(#function) \(String(describing: key))") } From 3ec654bb916d789fdab39c6db4f41ee7301542bc Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Tue, 29 Jul 2025 22:24:32 -0700 Subject: [PATCH 22/34] Log guard --- BlueprintUI/Sources/Internal/Logger.swift | 43 ++++++++++++++++------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index 235c66921..a05acfee9 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -200,7 +200,8 @@ extension Logger { // MARK: Environment Comparison - static func logEnvironmentKeySetEquivalencyComparisonStart(key: some Hashable) -> OSSignpostIntervalState { + 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, @@ -210,12 +211,14 @@ extension Logger { return token } - static func logEnvironmentKeySetEquivalencyComparisonEnd(_ token: OSSignpostIntervalState, key: some Hashable) { + 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 { + static func logEnvironmentEquivalencyComparisonStart(environment: Environment) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } let token = signposter.beginInterval( "Environment equivalency comparison", id: environment.fingerprint.value.signpost, @@ -225,22 +228,26 @@ extension Logger { return token } - static func logEnvironmentEquivalencyComparisonEnd(_ token: OSSignpostIntervalState, environment: Environment) { + 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)") } @@ -250,6 +257,7 @@ extension Logger { key: some Hashable, context: EquivalencyContext ) { + guard BlueprintLogging.isEnabled else { return } signposter.emitEvent( "Environment equivalency completed with non-equivalent result", id: environment.fingerprint.value.signpost, @@ -259,6 +267,7 @@ extension Logger { } 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, @@ -270,7 +279,8 @@ extension Logger { // MARK: ValidatingCache - static func logValidatingCacheValidationStart(key: some Hashable) -> OSSignpostIntervalState { + static func logValidatingCacheValidationStart(key: some Hashable) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } let token = signposter.beginInterval( "ValidatingCache validation", id: key.signpost, @@ -280,12 +290,14 @@ extension Logger { return token } - static func logValidatingCacheValidationEnd(_ token: OSSignpostIntervalState, key: some Hashable) { + 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 { + static func logValidatingCacheFreshValueCreationStart(key: some Hashable) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } let token = signposter.beginInterval( "ValidatingCache fresh value creation", id: key.signpost, @@ -295,28 +307,33 @@ extension Logger { return token } - static func logValidatingCacheFreshValueCreationEnd(_ token: OSSignpostIntervalState, key: some Hashable) { - signposter.endInterval("ValidatingCache fresh value creation", token, "\(String(describing: key))") + 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) { - signposter.emitEvent("ValidatingCache key miss", id: key.signpost, "\(String(describing: key))") + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache key miss", id: key.signpost) hook?("\(#function) \(String(describing: key))") } static func logValidatingCacheKeyHit(key: some Hashable) { - signposter.emitEvent("ValidatingCache key hit", id: key.signpost, "\(String(describing: key))") + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache key hit", id: key.signpost) hook?("\(#function) \(String(describing: key))") } static func logValidatingCacheHitAndValidationSuccess(key: some Hashable) { - signposter.emitEvent("ValidatingCache validation success", id: key.signpost, "\(String(describing: key))") + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache validation success", id: key.signpost) hook?("\(#function) \(String(describing: key))") } static func logValidatingCacheHitAndValidationFailure(key: some Hashable) { - signposter.emitEvent("ValidatingCache validation failure", id: key.signpost, "\(String(describing: key))") + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache validation failure", id: key.signpost) hook?("\(#function) \(String(describing: key))") } From 76cac018fc8605b62ecfb5be58a7f112e92d0f0d Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 30 Jul 2025 10:18:32 -0700 Subject: [PATCH 23/34] Tweak internal env api. --- BlueprintUI/Sources/Environment/Cache/CacheStorage.swift | 4 ++-- BlueprintUI/Sources/Environment/Environment.swift | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift index 9a3b91365..bdf1b3808 100644 --- a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift +++ b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift @@ -52,8 +52,8 @@ extension Environment { @_spi(CacheStorage) public var cacheStorage: CacheStorage { - get { self[internal: CacheStorageEnvironmentKey.self] } - set { self[internal: CacheStorageEnvironmentKey.self] = newValue } + get { self[CacheStorageEnvironmentKey.self] } + set { self[CacheStorageEnvironmentKey.self] = newValue } } } diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index f88e05b41..b01bd8b58 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -56,11 +56,7 @@ public struct Environment { let keybox = Keybox(key) let oldValue = values[keybox] values[keybox] = newValue - let token = Logger.logEnvironmentKeySetEquivalencyComparisonStart(key: keybox) - if !keybox.isEquivalent(newValue, oldValue, .all) { - fingerprint.modified() - } - Logger.logEnvironmentKeySetEquivalencyComparisonEnd(token, key: keybox) + fingerprint.modified() } } @@ -68,7 +64,7 @@ public struct Environment { values[keybox, default: keybox.type.defaultValue] } - 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 } From ea63760a07bfe6322be839d56194c322bb6029dc Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 30 Jul 2025 10:20:21 -0700 Subject: [PATCH 24/34] Enable logging for test. --- BlueprintUI/Tests/EnvironmentEquivalencyTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift index 340afe388..85541f037 100644 --- a/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift +++ b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift @@ -55,6 +55,7 @@ struct EnvironmentEquivalencyTests { } @Test func caching() { + BlueprintLogging.isEnabled = true var hookedResult: [String] = [] Logger.hook = { hookedResult.append($0) @@ -85,6 +86,7 @@ struct EnvironmentEquivalencyTests { } @Test func cascading() { + BlueprintLogging.isEnabled = true var hookedResult: [String] = [] Logger.hook = { hookedResult.append($0) From a599c4a37301ca13c9492ccb3528648b181f373d Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 30 Jul 2025 16:58:24 -0700 Subject: [PATCH 25/34] Snapshot --- .../Sources/Element/MeasurableStorage.swift | 2 +- .../Environment/Cache/ValidatingCache.swift | 26 +++--- .../Sources/Environment/Environment.swift | 86 +++++++++++++------ 3 files changed, 78 insertions(+), 36 deletions(-) diff --git a/BlueprintUI/Sources/Element/MeasurableStorage.swift b/BlueprintUI/Sources/Element/MeasurableStorage.swift index 88432b688..007947e0e 100644 --- a/BlueprintUI/Sources/Element/MeasurableStorage.swift +++ b/BlueprintUI/Sources/Element/MeasurableStorage.swift @@ -38,7 +38,7 @@ extension MeasurableStorage: CaffeinatedContentStorage { environment: environment, validationValue: validationKey, context: .elementSizing, - ) { + ) { environment in measurer(proposal, environment) } } diff --git a/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift index 07a887ec3..fe641cabf 100644 --- a/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift +++ b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift @@ -68,7 +68,7 @@ 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() + private var backing = ValidatingCache() public init() {} @@ -83,12 +83,14 @@ import Foundation key: Key, environment: Environment, context: EquivalencyContext, - create: () -> Value + create: (Environment) -> Value ) -> Value { backing.retrieveOrCreate(key: key) { environment.isEquivalent(to: $0, in: context) } create: { - (create(), environment.frozen) + environment.snapshottingAccess { environment in + create(environment) + } } } @@ -97,7 +99,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(CacheStorage) public struct EnvironmentAndValueValidatingCache: Sendable where Key: Hashable { - private var backing = ValidatingCache() + private var backing = ValidatingCache() public init() {} @@ -115,13 +117,15 @@ import Foundation environment: Environment, context: EquivalencyContext, validate: (AdditionalValidationData) -> Bool, - create: () -> (Value, AdditionalValidationData) + create: (Environment) -> (Value, AdditionalValidationData) ) -> Value { backing.retrieveOrCreate(key: key) { environment.isEquivalent(to: $0.0, in: context) && validate($0.1) } create: { - let (fresh, additional) = create() - return (fresh, (environment.frozen, additional)) + let x = environment.snapshottingAccess { environment in + create(environment) + } + return (x.0.0, (x.1, x.0.1)) } } @@ -143,12 +147,12 @@ import Foundation environment: Environment, validationValue: AdditionalValidationData, context: EquivalencyContext, - create: () -> (Value) + create: (Environment) -> (Value) ) -> Value { retrieveOrCreate(key: key, environment: environment, context: context) { $0.isEquivalent(to: validationValue, in: context) } create: { - (create(), validationValue) + (create($0), validationValue) } } @@ -170,12 +174,12 @@ import Foundation environment: Environment, validationValue: AdditionalValidationData, context: EquivalencyContext, - create: () -> (Value) + create: (Environment) -> (Value) ) -> Value { retrieveOrCreate(key: key, environment: environment, context: context) { $0 == validationValue } create: { - (create(), validationValue) + (create($0), validationValue) } } diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index b01bd8b58..2a16a58e2 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -44,6 +44,7 @@ public struct Environment { var fingerprint = ComparableFingerprint() private var values: [Keybox: Any] = [:] + private var snapshotting: SnapshottingEnvironment? private var internalValues: [ObjectIdentifier: Any] = [:] @@ -61,7 +62,11 @@ public struct Environment { } 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 } subscript(key: Key.Type) -> Key.Value where Key: InternalEnvironmentKey { @@ -88,27 +93,27 @@ public struct Environment { return merged } - var frozen: FrozenEnvironment { - FrozenEnvironment(fingerprint: fingerprint, values: values) - } - - func thawing(frozen: FrozenEnvironment) -> Environment { - var merged = self - merged.values = frozen.values - merged.fingerprint = frozen.fingerprint - 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) } - } -/// A frozen environment is immutable copy of the comparable elements of an Environment struct. -struct FrozenEnvironment { +/// 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. - let fingerprint: ComparableFingerprint - let values: [Environment.Keybox: Any] + var fingerprint: ComparableFingerprint + var values: [Environment.Keybox: Any] + +} +private final class SnapshottingEnvironment { + var value = EnvironmentSnapshot(fingerprint: .init(), values: [:]) } extension Environment: ContextuallyEquivalent { @@ -147,14 +152,37 @@ extension Environment: ContextuallyEquivalent { return true } - func isEquivalent(to other: FrozenEnvironment?, in context: EquivalencyContext) -> Bool { - guard let other else { return false } + 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 other.fingerprint.isEquivalent(to: fingerprint) { + if snapshot.fingerprint.isEquivalent(to: fingerprint) { Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) return true } - return isEquivalent(to: thawing(frozen: other), in: context) + let scope = Set(snapshot.values.keys.map(\.objectIdentifier)) + if let evaluated = cacheStorage.environmentComparisonCache[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[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[snapshot.fingerprint, context, scope] = true + return true + } @@ -164,14 +192,24 @@ extension CacheStorage { fileprivate struct EnvironmentFingerprintCache { + struct Key: Hashable { + let fingerprint: ComparableFingerprint.Value + let scope: Set? + } + typealias EquivalencyResult = [EquivalencyContext: Bool] - var storage: [ComparableFingerprint.Value: [EquivalencyContext: Bool]] = [:] + var storage: [Key: [EquivalencyContext: Bool]] = [:] - public subscript(fingerprint: ComparableFingerprint, context: EquivalencyContext) -> Bool? { + public subscript( + fingerprint: ComparableFingerprint, + context: EquivalencyContext, + scope: Set? = nil + ) -> Bool? { get { - if let exact = storage[fingerprint.value]?[context] { + let key = Key(fingerprint: fingerprint.value, scope: scope) + if let exact = storage[key]?[context] { return exact - } else if let allComparisons = storage[fingerprint.value] { + } 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. @@ -193,7 +231,7 @@ extension CacheStorage { } } set { - storage[fingerprint.value, default: [:]][context] = newValue + storage[Key(fingerprint: fingerprint.value, scope: scope), default: [:]][context] = newValue } } From 993355ba55f3b0289c92f3f875c66d844b9efc56 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 30 Jul 2025 17:00:46 -0700 Subject: [PATCH 26/34] Name --- BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift index fe641cabf..b48a14761 100644 --- a/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift +++ b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift @@ -122,10 +122,10 @@ import Foundation backing.retrieveOrCreate(key: key) { environment.isEquivalent(to: $0.0, in: context) && validate($0.1) } create: { - let x = environment.snapshottingAccess { environment in + let ((value, additional), snapshot) = environment.snapshottingAccess { environment in create(environment) } - return (x.0.0, (x.1, x.0.1)) + return (value, (snapshot, additional)) } } From 094dbcdd81248428cffa9e52d7d1db1950f63cbd Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Thu, 31 Jul 2025 10:14:58 -0700 Subject: [PATCH 27/34] Fix tests. --- .../Sources/Environment/Environment.swift | 32 +++++++++------ BlueprintUI/Tests/ValidatingCacheTests.swift | 40 +++++++++++-------- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index 2a16a58e2..621d38ee2 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -124,10 +124,7 @@ extension Environment: ContextuallyEquivalent { Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) return true } - if let evaluated = cacheStorage.environmentComparisonCache[other.fingerprint, context] ?? other.cacheStorage.environmentComparisonCache[ - fingerprint, - context - ] { + if let evaluated = cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] { Logger.logEnvironmentEquivalencyFingerprintCacheHit(environment: self) return evaluated } @@ -136,7 +133,7 @@ extension Environment: ContextuallyEquivalent { let keys = Set(values.keys).union(other.values.keys) for key in keys { guard key.isEquivalent(self[key], other[key], context) else { - cacheStorage.environmentComparisonCache[other.fingerprint, context] = false + cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] = false Logger.logEnvironmentEquivalencyCompletedWithNonEquivalence( environment: self, key: key, @@ -148,7 +145,7 @@ extension Environment: ContextuallyEquivalent { } Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) Logger.logEnvironmentEquivalencyCompletedWithEquivalence(environment: self, context: context) - cacheStorage.environmentComparisonCache[other.fingerprint, context] = true + cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] = true return true } @@ -160,7 +157,7 @@ extension Environment: ContextuallyEquivalent { return true } let scope = Set(snapshot.values.keys.map(\.objectIdentifier)) - if let evaluated = cacheStorage.environmentComparisonCache[snapshot.fingerprint, context, scope] { + if let evaluated = cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] { Logger.logEnvironmentEquivalencyFingerprintCacheHit(environment: self) return evaluated } @@ -168,7 +165,7 @@ extension Environment: ContextuallyEquivalent { let token = Logger.logEnvironmentEquivalencyComparisonStart(environment: self) for (key, value) in snapshot.values { guard key.isEquivalent(self[key], value, context) else { - cacheStorage.environmentComparisonCache[snapshot.fingerprint, context, scope] = false + cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] = false Logger.logEnvironmentEquivalencyCompletedWithNonEquivalence( environment: self, key: key, @@ -180,7 +177,7 @@ extension Environment: ContextuallyEquivalent { } Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) Logger.logEnvironmentEquivalencyCompletedWithEquivalence(environment: self, context: context) - cacheStorage.environmentComparisonCache[snapshot.fingerprint, context, scope] = true + cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] = true return true } @@ -193,20 +190,29 @@ extension CacheStorage { fileprivate struct EnvironmentFingerprintCache { struct Key: Hashable { - let fingerprint: ComparableFingerprint.Value + 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( - fingerprint: ComparableFingerprint, + lhs: ComparableFingerprint, + rhs: ComparableFingerprint, context: EquivalencyContext, scope: Set? = nil ) -> Bool? { get { - let key = Key(fingerprint: fingerprint.value, scope: scope) + let key = Key(lhs.value, rhs.value, scope: scope) if let exact = storage[key]?[context] { return exact } else if let allComparisons = storage[key] { @@ -231,7 +237,7 @@ extension CacheStorage { } } set { - storage[Key(fingerprint: fingerprint.value, scope: scope), default: [:]][context] = newValue + storage[Key(lhs.value, rhs.value, scope: scope), default: [:]][context] = newValue } } diff --git a/BlueprintUI/Tests/ValidatingCacheTests.swift b/BlueprintUI/Tests/ValidatingCacheTests.swift index bf8d18c88..0d9ae9f3f 100644 --- a/BlueprintUI/Tests/ValidatingCacheTests.swift +++ b/BlueprintUI/Tests/ValidatingCacheTests.swift @@ -78,24 +78,28 @@ struct EnvironmentValidatingCacheTests { var environment = Environment() environment[ExampleKey.self] = 1 let one = cache.retrieveOrCreate(key: "Hello", environment: environment, context: .all) { - "One" + _ = $0[ExampleKey.self] + return "One" } #expect(one == "One") let two = cache.retrieveOrCreate(key: "Hello", environment: environment, context: .all) { - "Two" + _ = $0[ExampleKey.self] + return "Two" } #expect(two == "One") let three = cache.retrieveOrCreate(key: "KeyMiss", environment: environment, context: .all) { - "Three" + _ = $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) { - "Four" + _ = $0[ExampleKey.self] + return "Four" } #expect(four == "Four") } @@ -116,7 +120,8 @@ struct EnvironmentAndValueValidatingCacheTests { validationValue: "Validate", context: .all ) { - "One" + _ = $0[ExampleKey.self] + return "One" } #expect(one == "One") @@ -126,7 +131,8 @@ struct EnvironmentAndValueValidatingCacheTests { validationValue: "Validate", context: .all ) { - "Two" + _ = $0[ExampleKey.self] + return "Two" } #expect(two == "One") @@ -136,7 +142,8 @@ struct EnvironmentAndValueValidatingCacheTests { validationValue: "Validate", context: .all ) { - "Three" + _ = $0[ExampleKey.self] + return "Three" } #expect(three == "Three") @@ -148,7 +155,8 @@ struct EnvironmentAndValueValidatingCacheTests { validationValue: "Validate", context: .all ) { - "Four" + _ = $0[ExampleKey.self] + return "Four" } #expect(four == "Four") @@ -157,7 +165,7 @@ struct EnvironmentAndValueValidatingCacheTests { environment: differentEnvironment, validationValue: "Invalid", context: .all - ) { + ) { _ in "Five" } #expect(five == "Five") @@ -186,7 +194,7 @@ struct EnvironmentAndValueValidatingCacheTests { environment: environment, validationValue: elementOne, context: .elementSizing - ) { + ) { _ in sizeForElement(element: elementOne) } #expect(firstSize == CGSize(width: 50, height: 100)) @@ -198,7 +206,7 @@ struct EnvironmentAndValueValidatingCacheTests { environment: environment, validationValue: elementTwo, context: .elementSizing - ) { + ) { _ in sizeForElement(element: elementTwo) } #expect(secondSize == CGSize(width: 20, height: 100)) @@ -210,7 +218,7 @@ struct EnvironmentAndValueValidatingCacheTests { environment: environment, validationValue: elementOne, context: .elementSizing - ) { + ) { _ in sizeForElement(element: elementOne) } #expect(firstSizeAgain == CGSize(width: 50, height: 100)) @@ -222,7 +230,7 @@ struct EnvironmentAndValueValidatingCacheTests { environment: environment, validationValue: elementOneModified, context: .elementSizing - ) { + ) { _ in sizeForElement(element: elementOneModified) } #expect(firstSizeWithNewElement == CGSize(width: 110, height: 100)) @@ -234,7 +242,7 @@ struct EnvironmentAndValueValidatingCacheTests { environment: environment, validationValue: elementOneModified, context: .elementSizing - ) { + ) { _ in sizeForElement(element: elementOneModified) } #expect(firstSizeWithNewElementAgain == CGSize(width: 110, height: 100)) @@ -246,7 +254,7 @@ struct EnvironmentAndValueValidatingCacheTests { environment: environment, validationValue: elementOne, context: .elementSizing - ) { + ) { _ in sizeForElement(element: elementOne) } #expect(originalFirstSizeAgain == CGSize(width: 50, height: 100)) @@ -259,7 +267,7 @@ struct EnvironmentAndValueValidatingCacheTests { environment: environment, validationValue: elementOneModified, context: .elementSizing - ) { + ) { _ in sizeForElement(element: elementOne) } #expect(firstSizeWithNewEnvironment == CGSize(width: 50, height: 100)) From 33979eff820b99df837afc4d6759c67f523c5715 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Mon, 11 Aug 2025 17:56:52 -0700 Subject: [PATCH 28/34] Feedback. --- .../Sources/Element/MeasurableStorage.swift | 2 +- .../Sources/Environment/Cache/CacheKey.swift | 15 ++++++++++----- .../Sources/Environment/Cache/CacheStorage.swift | 2 +- BlueprintUI/Sources/Environment/Environment.swift | 4 ++-- .../Sources/AttributedLabel.swift | 4 ++-- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/BlueprintUI/Sources/Element/MeasurableStorage.swift b/BlueprintUI/Sources/Element/MeasurableStorage.swift index 007947e0e..da75840f5 100644 --- a/BlueprintUI/Sources/Element/MeasurableStorage.swift +++ b/BlueprintUI/Sources/Element/MeasurableStorage.swift @@ -70,7 +70,7 @@ extension MeasurableStorage { extension CacheStorage { - private struct MeasurableStorageCacheKey: CacheKey { + private struct MeasurableStorageCacheKey: CacheStorage.Key { static var emptyValue = EnvironmentAndValueValidatingCache< MeasurableStorage.MeasurableSizeKey, CGSize, diff --git a/BlueprintUI/Sources/Environment/Cache/CacheKey.swift b/BlueprintUI/Sources/Environment/Cache/CacheKey.swift index e6fc2667a..1d3be132a 100644 --- a/BlueprintUI/Sources/Environment/Cache/CacheKey.swift +++ b/BlueprintUI/Sources/Environment/Cache/CacheKey.swift @@ -3,13 +3,13 @@ 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. +/// key's `CacheStorage.Key.Value` associated value. /// /// ## Example /// /// Usually a key is implemented with an uninhabited type, such an empty enum. /// -/// enum WidgetCountsKey: CacheKey { +/// enum WidgetCountsKey: CacheStorage.Key { /// static let emptyValue: [WidgetID: Int] = [:] /// } /// @@ -21,7 +21,12 @@ import Foundation /// set { self[WidgetCountsKey.self] = newValue } /// } /// } -public protocol CacheKey { - associatedtype Value - static var emptyValue: Self.Value { get } +/// +extension CacheStorage { + + public protocol Key { + associatedtype Value + static var emptyValue: Self.Value { get } + } + } diff --git a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift index bdf1b3808..195f5238f 100644 --- a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift +++ b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift @@ -24,7 +24,7 @@ import UIKit #endif } - public subscript(key: KeyType.Type) -> KeyType.Value where KeyType: CacheKey { + public subscript(key: KeyType.Type) -> KeyType.Value where KeyType: CacheStorage.Key { get { storage[ObjectIdentifier(key), default: KeyType.emptyValue] as! KeyType.Value } diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index 621d38ee2..4cbfbf2ba 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -244,8 +244,8 @@ extension CacheStorage { } /// A cache of previously compared environments and their results. - private struct EnvironmentComparisonCacheKey: CacheKey { - static var emptyValue = EnvironmentFingerprintCache() + private struct EnvironmentComparisonCacheKey: CacheStorage.Key { + static let emptyValue = EnvironmentFingerprintCache() } fileprivate var environmentComparisonCache: EnvironmentFingerprintCache { diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index a301d00c2..6eab633e3 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -957,8 +957,8 @@ fileprivate struct AttributedStringNormalizationKey: Hashable { } extension CacheStorage { - private struct AttributedStringNormalizationCacheKey: CacheKey { - static var emptyValue: [AttributedStringNormalizationKey: NSAttributedString] = [:] + private struct AttributedStringNormalizationCacheKey: CacheStorage.Key { + static let emptyValue: [AttributedStringNormalizationKey: NSAttributedString] = [:] } fileprivate var attributedStringNormalizationCache: [AttributedStringNormalizationKey: NSAttributedString] { From c8ab1d00819aa82fb203eb37ebc323c24ef24ece Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 1 Oct 2025 13:53:56 -0700 Subject: [PATCH 29/34] Off by default --- BlueprintUI/Sources/Layout/LayoutOptions.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/BlueprintUI/Sources/Layout/LayoutOptions.swift b/BlueprintUI/Sources/Layout/LayoutOptions.swift index 8ff17dbc4..d10bad07e 100644 --- a/BlueprintUI/Sources/Layout/LayoutOptions.swift +++ b/BlueprintUI/Sources/Layout/LayoutOptions.swift @@ -10,10 +10,10 @@ public struct LayoutOptions: Hashable { public static let `default` = LayoutOptions( hintRangeBoundaries: true, searchUnconstrainedKeys: true, - measureableStorageCache: true, - stringNormalizationCache: true, - skipUnneededSetNeedsViewHierarchyUpdates: true, - labelAttributedStringCache: true + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false ) /// Enables aggressive cache hinting along the boundaries of the range between constraints and From 6e8ffbbc40bc8916e1ec9edd28ac941160410427 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Fri, 28 Nov 2025 18:03:06 -0800 Subject: [PATCH 30/34] PR feedback cleanup --- BlueprintUI/Sources/Element/MeasurableStorage.swift | 6 +++--- BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift | 7 ------- 2 files changed, 3 insertions(+), 10 deletions(-) delete mode 100644 BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift diff --git a/BlueprintUI/Sources/Element/MeasurableStorage.swift b/BlueprintUI/Sources/Element/MeasurableStorage.swift index e4d604318..11f43b268 100644 --- a/BlueprintUI/Sources/Element/MeasurableStorage.swift +++ b/BlueprintUI/Sources/Element/MeasurableStorage.swift @@ -32,7 +32,7 @@ extension MeasurableStorage: CaffeinatedContentStorage { return measurer(proposal, environment) } - let key = MeasurableSizeKey(path: node.path, max: proposal.maximum) + let key = MeasurableSizeKey(path: node.path, proposedSizeConstraint: proposal) return environment.hostingViewContext.measurableStorageCache.retrieveOrCreate( key: key, environment: environment, @@ -57,11 +57,11 @@ extension MeasurableStorage { fileprivate struct MeasurableSizeKey: Hashable { let path: String - let max: CGSize + let proposedSizeConstraint: SizeConstraint func hash(into hasher: inout Hasher) { path.hash(into: &hasher) - max.hash(into: &hasher) + proposedSizeConstraint.hash(into: &hasher) } } diff --git a/BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift b/BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift deleted file mode 100644 index 2ea946699..000000000 --- a/BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// EnvironmentEntangledCacheTests.swift -// Development -// -// Created by Max Goedjen on 7/23/25. -// - From e98986111758b844b93fef8207b36dc1449ff7cd Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Tue, 2 Dec 2025 15:07:22 -0800 Subject: [PATCH 31/34] 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 595c0bfafadee8a4bc6c92ffcf1e94a59e434782 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Tue, 2 Dec 2025 15:28:42 -0800 Subject: [PATCH 32/34] 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 From 9ae20e12074cddf7b4d51025b7edb7cf0702c408 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Tue, 23 Dec 2025 21:38:27 -0800 Subject: [PATCH 33/34] Name --- BlueprintUI/Sources/Element/ElementContent.swift | 6 +++--- BlueprintUI/Sources/Element/MeasurableStorage.swift | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/BlueprintUI/Sources/Element/ElementContent.swift b/BlueprintUI/Sources/Element/ElementContent.swift index a1faef34e..d5dea9d75 100644 --- a/BlueprintUI/Sources/Element/ElementContent.swift +++ b/BlueprintUI/Sources/Element/ElementContent.swift @@ -251,13 +251,13 @@ extension ElementContent { /// Initializes a new `ElementContent` with no children that delegates to the provided measure function. /// - /// - parameter validationKey: If present, measureFunction will attempt to cache sizing based on the path of the node. validationKey will be evaluated to ensure that the result is valid. + /// - parameter cacheKey: If present, measureFunction will attempt to cache sizing based on the path of the node and the cacheKey. /// - parameter measureFunction: How to measure the `ElementContent` in the given `SizeConstraint` and `Environment`. public init( - validationKey: some CrossLayoutCacheable, + cacheKey: some CrossLayoutCacheable, measureFunction: @escaping (SizeConstraint, Environment) -> CGSize ) { - storage = MeasurableStorage(validationKey: validationKey, measurer: measureFunction) + storage = MeasurableStorage(cacheKey: cacheKey, measurer: measureFunction) } /// Initializes a new `ElementContent` with no children that uses the provided intrinsic size for measuring. diff --git a/BlueprintUI/Sources/Element/MeasurableStorage.swift b/BlueprintUI/Sources/Element/MeasurableStorage.swift index 11f43b268..8ee18f4ea 100644 --- a/BlueprintUI/Sources/Element/MeasurableStorage.swift +++ b/BlueprintUI/Sources/Element/MeasurableStorage.swift @@ -7,16 +7,16 @@ struct MeasurableStorage: ContentStorage { let childCount = 0 - let validationKey: AnyCrossLayoutCacheable? + let cacheKey: AnyCrossLayoutCacheable? let measurer: (SizeConstraint, Environment) -> CGSize - init(validationKey: some CrossLayoutCacheable, measurer: @escaping (SizeConstraint, Environment) -> CGSize) { - self.validationKey = AnyCrossLayoutCacheable(validationKey) + init(cacheKey: some CrossLayoutCacheable, measurer: @escaping (SizeConstraint, Environment) -> CGSize) { + self.cacheKey = AnyCrossLayoutCacheable(cacheKey) self.measurer = measurer } init(measurer: @escaping (SizeConstraint, Environment) -> CGSize) { - validationKey = nil + cacheKey = nil self.measurer = measurer } } @@ -28,7 +28,7 @@ extension MeasurableStorage: CaffeinatedContentStorage { environment: Environment, node: LayoutTreeNode ) -> CGSize { - guard environment.layoutMode.options.measureableStorageCache, let validationKey else { + guard environment.layoutMode.options.measureableStorageCache, let cacheKey else { return measurer(proposal, environment) } @@ -36,7 +36,7 @@ extension MeasurableStorage: CaffeinatedContentStorage { return environment.hostingViewContext.measurableStorageCache.retrieveOrCreate( key: key, environment: environment, - validationValue: validationKey, + validationValue: cacheKey, context: .elementSizing, ) { environment in measurer(proposal, environment) From 67d3f191b60d699b1c31c9649de3f8fc20798ebb Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Tue, 23 Dec 2025 21:41:09 -0800 Subject: [PATCH 34/34] Restore attributed string sizing merge --- .../Sources/AttributedLabel.swift | 83 ++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index 4d73d718b..1e990899d 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -133,7 +133,7 @@ public struct AttributedLabel: Element, Hashable, CrossLayoutCacheable { public var content: ElementContent { - ElementContent(validationKey: self) { constraint, environment -> CGSize in + ElementContent(cacheKey: self) { constraint, environment -> CGSize in let text = displayableAttributedText(environment: environment) let label = Self.prototypeLabel label.update(model: self, text: text, environment: environment, isMeasuring: true) @@ -211,6 +211,9 @@ extension AttributedLabel { } } + // Store bounding shapes in this cache to avoid costly recalculations + private var boundingShapeCache: [Link: Link.BoundingShape] = [:] + override var accessibilityCustomRotors: [UIAccessibilityCustomRotor]? { set { assertionFailure("accessibilityCustomRotors is not settable.") } get { !linkElements.isEmpty ? [linkElements.accessibilityRotor(systemType: .link)] : [] } @@ -222,6 +225,46 @@ extension AttributedLabel { var urlHandler: URLHandler? + override init(frame: CGRect) { + super.init(frame: frame) + + if #available(iOS 17.0, *) { + registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { ( + view: LabelView, + previousTraitCollection: UITraitCollection + ) in + view.invalidateLinkBoundingShapeCaches() + } + } else { + NotificationCenter + .default + .addObserver( + self, + selector: #selector(sizeCategoryChanged(notification:)), + name: UIContentSizeCategory.didChangeNotification, + object: nil + ) + } + } + + deinit { + if #available(iOS 17.0, *) { + // Do nothing + } else { + NotificationCenter + .default + .removeObserver(self) + } + } + + @objc private func sizeCategoryChanged(notification: Notification) { + invalidateLinkBoundingShapeCaches() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + func update(model: AttributedLabel, text: NSAttributedString, environment: Environment, isMeasuring: Bool) { let previousAttributedText = isMeasuring ? nil : attributedText @@ -251,6 +294,8 @@ extension AttributedLabel { layoutDirection = environment.layoutDirection if !isMeasuring { + invalidateLinkBoundingShapeCaches() + if previousAttributedText != attributedText { links = attributedLinks(in: model.attributedText) + detectedDataLinks(in: model.attributedText) accessibilityLabel = accessibilityLabel( @@ -643,8 +688,38 @@ extension AttributedLabel { trackingLinks = nil applyLinkColors() } + + override func layoutSubviews() { + super.layoutSubviews() + + invalidateLinkBoundingShapeCaches() + } + + func boundingShape(for link: Link) -> Link.BoundingShape { + if let cachedShape = boundingShapeCache[link] { + return cachedShape + } + + let calculatedShape = link.calculateBoundingShape() + boundingShapeCache[link] = calculatedShape + return calculatedShape + } + + private func invalidateLinkBoundingShapeCaches() { + boundingShapeCache.removeAll() + } } +} +extension AttributedLabel.LabelView { + // Without this, we were seeing console messages like the following: + // "LabelView implements focusItemsInRect: - caching for linear focus movement is limited as long as this view is on screen." + // It's unclear as to why they are appearing despite using the API in the intended manner. + // To suppress the messages, we implemented this function much like Apple did with `UITableView`, + // `UICollectionView`, etc. + @objc private class func _supportsInvalidatingFocusCache() -> Bool { + true + } } extension AttributedLabel { @@ -673,6 +748,10 @@ extension AttributedLabel { } var boundingShape: BoundingShape { + container?.boundingShape(for: self) ?? calculateBoundingShape() + } + + fileprivate func calculateBoundingShape() -> BoundingShape { guard let container = container, let textStorage = container.makeTextStorage(), let layoutManager = textStorage.layoutManagers.first, @@ -775,7 +854,7 @@ extension AttributedLabel { override var accessibilityPath: UIBezierPath? { set { assertionFailure("cannot set accessibilityPath") } get { - if let path = link.boundingShape.path, let container = link.container { + if let path = link.boundingShape.path?.copy() as? UIBezierPath, let container = link.container { return UIAccessibility.convertToScreenCoordinates(path, in: container) }