Skip to content
Open
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
3abebaf
CacheStorage
maxg-square Jul 16, 2025
9b87e2a
Add MeasurableStorage caching.
maxg-square Jul 16, 2025
6802a22
Add skipUnneededSetNeedsViewHierarchyUpdates
maxg-square Jul 16, 2025
6429e56
Cache string normalization
maxg-square Jul 16, 2025
76aee10
Fix key name
maxg-square Jul 16, 2025
44aa682
Fix testkey
maxg-square Jul 16, 2025
10d11e9
Merge branch 'maxg/cache_1_equiv' into maxg/cache_2_envcache
maxg-square Jul 16, 2025
ee32e1c
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3a_measurable
maxg-square Jul 16, 2025
23a1dee
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3b_needsvhu
maxg-square Jul 16, 2025
dad612b
Merge branch 'maxg/cache_2_envcache' into cache_3c_stringnorm
maxg-square Jul 16, 2025
7c6b2dc
Merge branch 'maxg/cache_1_equivalency' into maxg/cache_2_envcache
maxg-square Jul 16, 2025
e46e778
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3a_measurable
maxg-square Jul 16, 2025
5faf9e1
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3b_needsvhu
maxg-square Jul 16, 2025
5ef770e
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3c_stringnorm
maxg-square Jul 16, 2025
6dafc50
Fix HSC tests
maxg-square Jul 17, 2025
56b751d
Fix HSC tests
maxg-square Jul 17, 2025
4a126e7
Fix HSC tests
maxg-square Jul 17, 2025
499ee96
Fix HSC tests
maxg-square Jul 17, 2025
15d0c5d
Tmp merge
maxg-square Jul 17, 2025
0d413d3
Tmp merge
maxg-square Jul 17, 2025
94e9b23
Merge
maxg-square Jul 17, 2025
1c45f7f
LASC
maxg-square Jul 17, 2025
9afcdd1
Merge
maxg-square Jul 25, 2025
42104f9
Merge branch 'maxg/cache_1_equivalency' into maxg/cache_2_envcache
maxg-square Jul 25, 2025
398dc40
Merge.
maxg-square Jul 25, 2025
22ed032
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3a_measurable
maxg-square Jul 25, 2025
be9dd21
Merge
maxg-square Jul 25, 2025
942c1fa
Tests and fixes
maxg-square Jul 25, 2025
6519418
More tests
maxg-square Jul 25, 2025
e8c27c0
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3a_measurable
maxg-square Jul 25, 2025
872bcaa
Merge
maxg-square Jul 25, 2025
afbbc3c
More tests
maxg-square Jul 25, 2025
f5ea2eb
Merge branch 'main' into maxg/cache_3x_all
maxg-square Jul 25, 2025
f4c5c2e
Tweak env tests
maxg-square Jul 28, 2025
c2e2ddc
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3x_all
maxg-square Jul 28, 2025
9c0da9f
Existentials for Xcode 15
maxg-square Jul 28, 2025
1842de6
Merge branch 'main' into maxg/cache_2_envcache
maxg-square Jul 28, 2025
0f19b47
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3x_all
maxg-square Jul 28, 2025
707cc8d
Log guard
maxg-square Jul 30, 2025
3ec654b
Log guard
maxg-square Jul 30, 2025
76cac01
Tweak internal env api.
maxg-square Jul 30, 2025
ea63760
Enable logging for test.
maxg-square Jul 30, 2025
f35c210
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3x_all
maxg-square Jul 30, 2025
a599c4a
Snapshot
maxg-square Jul 30, 2025
993355b
Name
maxg-square Jul 31, 2025
094dbcd
Fix tests.
maxg-square Jul 31, 2025
33979ef
Feedback.
maxg-square Aug 12, 2025
1f9b9be
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3x_all
maxg-square Sep 4, 2025
c8ab1d0
Off by default
maxg-square Oct 1, 2025
7dc49fc
Merge forward changes from pr 2
maxg-square Nov 27, 2025
6e8ffbb
PR feedback cleanup
maxg-square Nov 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions BlueprintUI/Sources/BlueprintView/BlueprintView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -52,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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.layoutMode is just one customization point — you should probably use something like:

self.layoutMode ?? RenderContext.current?.layoutMode ?? environment.layoutMode

which I think will match the actual mode used during layout.

to: environment,
in: .all
) {
return
}

setNeedsViewHierarchyUpdate()
}
Expand Down Expand Up @@ -86,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)

Expand Down Expand Up @@ -148,6 +164,7 @@ public final class BlueprintView: UIView {

self.element = element
self.environment = environment
self.environment.cacheStorage = cacheStorage
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't need to set this here, it'll be applied during makeEnvironment. self.environment is the "base"


rootController = NativeViewController(
node: NativeViewNode(
Expand Down Expand Up @@ -542,9 +559,13 @@ public final class BlueprintView: UIView {
environment.layoutMode = layoutMode
}

environment.cacheStorage = cacheStorage

return environment
}



private func handleAppeared() {
rootController.traverse { node in
node.onAppear?()
Expand Down
11 changes: 11 additions & 0 deletions BlueprintUI/Sources/Element/ElementContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,17 @@ extension ElementContent {
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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pitching name measureCachingKey over validationKey — as a consumer there's no "validation" aspect to it, it's used to cache measurements

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.
public init(intrinsicSize: CGSize) {
self = ElementContent(measureFunction: { _ in intrinsicSize })
Expand Down
62 changes: 61 additions & 1 deletion BlueprintUI/Sources/Element/MeasurableStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,18 @@ struct MeasurableStorage: ContentStorage {

let childCount = 0

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 {
Expand All @@ -17,7 +28,19 @@ extension MeasurableStorage: CaffeinatedContentStorage {
environment: Environment,
node: LayoutTreeNode
) -> CGSize {
measurer(proposal, environment)
guard environment.layoutMode.options.measureableStorageCache, let validationKey else {
return measurer(proposal, environment)
}

let key = MeasurableSizeKey(path: node.path, max: proposal.maximum)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

better to use proposal directly than maximum I think — the latter is a headache to deal with and something I'd like to deprecate

return environment.cacheStorage.measurableStorageCache.retrieveOrCreate(
key: key,
environment: environment,
validationValue: validationKey,
context: .elementSizing,
) { environment in
measurer(proposal, environment)
}
}

func performCaffeinatedLayout(
Expand All @@ -28,3 +51,40 @@ extension MeasurableStorage: CaffeinatedContentStorage {
[]
}
}

extension MeasurableStorage {

fileprivate struct MeasurableSizeKey: Hashable {

let path: String
let max: CGSize

func hash(into hasher: inout Hasher) {
path.hash(into: &hasher)
max.hash(into: &hasher)
}

}

}

extension CacheStorage {

private struct MeasurableStorageCacheKey: CacheStorage.Key {
static var emptyValue = EnvironmentAndValueValidatingCache<
MeasurableStorage.MeasurableSizeKey,
CGSize,
AnyContextuallyEquivalent
>()
}

fileprivate var measurableStorageCache: EnvironmentAndValueValidatingCache<
MeasurableStorage.MeasurableSizeKey,
CGSize,
AnyContextuallyEquivalent
> {
get { self[MeasurableStorageCacheKey.self] }
set { self[MeasurableStorageCacheKey.self] = newValue }
}

}
15 changes: 10 additions & 5 deletions BlueprintUI/Sources/Environment/Cache/CacheKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]
/// }
///
Expand All @@ -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 }
}

}
2 changes: 1 addition & 1 deletion BlueprintUI/Sources/Environment/Cache/CacheStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import UIKit
#endif
}

public subscript<KeyType>(key: KeyType.Type) -> KeyType.Value where KeyType: CacheKey {
public subscript<KeyType>(key: KeyType.Type) -> KeyType.Value where KeyType: CacheStorage.Key {
get {
storage[ObjectIdentifier(key), default: KeyType.emptyValue] as! KeyType.Value
}
Expand Down
4 changes: 2 additions & 2 deletions BlueprintUI/Sources/Environment/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 23 additions & 9 deletions BlueprintUI/Sources/Layout/LayoutMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -41,15 +41,29 @@ 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 options.stringNormalizationCache {
optionsDescription.append("stringNormalizationCache")
}
if options.skipUnneededSetNeedsViewHierarchyUpdates {
optionsDescription.append("needsViewHierarchyUpdates")
}
if options.labelAttributedStringCache {
optionsDescription.append("labelAttributedStringCache")
}
if optionsDescription.isEmpty {
return "Caffeinated"
} else {
return "Caffeinated \(optionsDescription.joined(separator: "+"))"
}
}
}
Expand Down
34 changes: 31 additions & 3 deletions BlueprintUI/Sources/Layout/LayoutOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ 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: false,
stringNormalizationCache: false,
skipUnneededSetNeedsViewHierarchyUpdates: false,
labelAttributedStringCache: false
)

/// Enables aggressive cache hinting along the boundaries of the range between constraints and
Expand All @@ -22,8 +26,32 @@ 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

/// 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

/// Caches MarketLabel attributed string generation
public var labelAttributedStringCache: Bool

public init(
hintRangeBoundaries: Bool,
searchUnconstrainedKeys: Bool,
measureableStorageCache: Bool,
stringNormalizationCache: Bool,
skipUnneededSetNeedsViewHierarchyUpdates: Bool,
labelAttributedStringCache: Bool
) {
self.hintRangeBoundaries = hintRangeBoundaries
self.searchUnconstrainedKeys = searchUnconstrainedKeys
self.measureableStorageCache = measureableStorageCache
self.stringNormalizationCache = stringNormalizationCache
self.skipUnneededSetNeedsViewHierarchyUpdates = skipUnneededSetNeedsViewHierarchyUpdates
self.labelAttributedStringCache = labelAttributedStringCache
}
}
7 changes: 7 additions & 0 deletions BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//
// EnvironmentEntangledCacheTests.swift
// Development
//
// Created by Max Goedjen on 7/23/25.
//

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO?

Loading
Loading