Refactor: Selective RenderCache invalidation for performance

- Remove pulse-triggered cache clearing (was destroying entire cache
  ~7x/sec during focus animation, yielding 0% hit rate)
- Add RenderCache.clearAffected(by:) for identity-aware invalidation
- Wire StateBox to its ViewIdentity for targeted cache clearing on
  @State changes (sibling subtrees retain their cached buffers)
- Add 5 new tests for clearAffected covering ancestor, descendant,
  sibling preservation, exact match, and empty cache scenarios
- Update EquatableView documentation with pulse/focus guidance
This commit is contained in:
phranck
2026-02-14 15:03:51 +01:00
parent 3835ed87e3
commit 04d71f42c2
5 changed files with 138 additions and 31 deletions
-20
View File
@@ -119,12 +119,6 @@ internal final class RenderLoop<A: App> {
/// callers to manually invalidate the cache.
private var lastEnvironmentSnapshot: EnvironmentSnapshot?
/// The pulse phase from the previous frame.
///
/// When the pulse phase changes, the render cache is cleared so that
/// focus indicators (pulsing ``) update correctly.
private var lastPulsePhase: Double = 0
/// Whether the first frame has been rendered.
///
/// On the first frame, we perform a "measurement pass" to determine
@@ -192,7 +186,6 @@ extension RenderLoop {
environment.pulsePhase = pulsePhase
environment.cursorTimer = cursorTimer
invalidateCacheIfEnvironmentChanged(environment: environment)
invalidateCacheIfPulsePhaseChanged(pulsePhase: pulsePhase)
// Set up state hydration context BEFORE evaluating app.body so that
// views constructed inside WindowGroup { ... } closures get persistent
@@ -382,19 +375,6 @@ private extension RenderLoop {
lastEnvironmentSnapshot = currentSnapshot
}
/// Clears the render cache when the pulse phase changes.
///
/// This ensures focus indicators (pulsing ``) animate correctly.
/// The pulse timer fires ~7 times per second, so the cache is cleared
/// frequently during pulse animation but this is necessary for the
/// breathing effect to be visible.
func invalidateCacheIfPulsePhaseChanged(pulsePhase: Double) {
if pulsePhase != lastPulsePhase {
tuiContext.renderCache.clearAll()
lastPulsePhase = pulsePhase
}
}
/// Renders a scene by delegating to `SceneRenderable`.
func renderScene<S: Scene>(_ scene: S, context: RenderContext) -> FrameBuffer {
if let renderable = scene as? SceneRenderable {
+8 -2
View File
@@ -51,11 +51,17 @@ import TUIkitCore
/// so the view struct compares as equal even when state changed)
/// - Views that change every frame (the cache overhead adds no value)
/// - Views that depend on environment values that change frequently
/// - **Views containing focused interactive elements** (Button, Toggle, Slider,
/// etc.) whose focus indicator animates via pulse phase. The cached buffer
/// would show a frozen pulse animation.
///
/// ## Cache Invalidation
///
/// The render cache is **fully cleared** on every `@State` change. Between
/// state changes (animation ticks, pulse frames), the cache is fully active.
/// The render cache is selectively cleared when `@State` values change:
/// only cache entries in the ancestor/descendant path of the changed state
/// are invalidated. Sibling subtrees retain their cached buffers.
/// Pulse animation changes do **not** invalidate the cache, which is why
/// subtrees containing focused interactive views should not be wrapped.
///
/// - SeeAlso: ``View/equatable()``
public struct EquatableView<Content: View & Equatable>: View {
+38 -6
View File
@@ -70,12 +70,22 @@ public final class RenderCache: @unchecked Sendable {
/// Number of times ``clearAll()`` was called.
public var clears: Int = 0
/// Number of times ``clearAffected(by:)`` was called.
public var subtreeClears: Int = 0
/// Creates a new Stats instance with default values.
public init(hits: Int = 0, misses: Int = 0, stores: Int = 0, clears: Int = 0) {
public init(
hits: Int = 0,
misses: Int = 0,
stores: Int = 0,
clears: Int = 0,
subtreeClears: Int = 0
) {
self.hits = hits
self.misses = misses
self.stores = stores
self.clears = clears
self.subtreeClears = subtreeClears
}
/// The total number of lookups (hits + misses).
@@ -92,7 +102,8 @@ public final class RenderCache: @unchecked Sendable {
hits: hits - earlier.hits,
misses: misses - earlier.misses,
stores: stores - earlier.stores,
clears: clears - earlier.clears
clears: clears - earlier.clears,
subtreeClears: subtreeClears - earlier.subtreeClears
)
}
}
@@ -253,16 +264,36 @@ extension RenderCache {
/// Clears all cached entries.
///
/// Called when any `@State` value changes, because state changes
/// can propagate to any subtree through bindings or environment.
/// Also called by `RenderLoop` when environment values change
/// (theme, appearance).
/// Called by `RenderLoop` when global environment values change
/// (theme, appearance) that affect all views simultaneously.
/// For state changes that only affect a subtree, prefer
/// ``clearAffected(by:)``.
public func clearAll() {
stats.clears += 1
logDebug("CLEAR ALL (\(entries.count) entries)")
entries.removeAll(keepingCapacity: true)
}
/// Clears cached entries affected by a state change at the given identity.
///
/// Instead of clearing the entire cache, this removes only entries whose
/// identity is an ancestor of, a descendant of, or equal to the changed
/// identity. Sibling subtrees retain their cached buffers.
///
/// - Parameter identity: The identity of the view whose state changed.
public func clearAffected(by identity: ViewIdentity) {
stats.subtreeClears += 1
let staleKeys = entries.keys.filter { cached in
cached == identity
|| cached.isAncestor(of: identity)
|| identity.isAncestor(of: cached)
}
for key in staleKeys {
entries.removeValue(forKey: key)
}
logDebug("CLEAR AFFECTED by \(identity.path): \(staleKeys.count) of \(entries.count + staleKeys.count) entries")
}
/// Removes all cached entries, resets GC state, and clears statistics.
public func reset() {
entries.removeAll()
@@ -290,6 +321,7 @@ extension RenderCache {
logDebug(
"FRAME — hits: \(frame.hits), misses: \(frame.misses), "
+ "stores: \(frame.stores), clears: \(frame.clears), "
+ "subtreeClears: \(frame.subtreeClears), "
+ "entries: \(entries.count), hit rate: \(rate)"
)
}
+15 -1
View File
@@ -77,9 +77,11 @@ extension StateStorage {
/// - Returns: The persistent `Storage` object for this property.
public func storage<Value>(for key: StateKey, default defaultValue: Value) -> StateBox<Value> {
if let existing = values[key] as? StateBox<Value> {
existing.identity = key.identity
return existing
}
let fresh = StateBox(defaultValue)
fresh.identity = key.identity
values[key] = fresh
return fresh
}
@@ -140,11 +142,23 @@ extension StateStorage {
/// of the `@State` struct (which uses `nonmutating set`).
///
/// On value change, signals a re-render through `RenderNotifier`.
/// Cache invalidation is identity-aware: only the affected subtree is
/// cleared instead of the entire cache.
public final class StateBox<Value>: @unchecked Sendable {
/// The identity of the view that owns this state property.
///
/// Set during hydration from ``StateStorage``. Used for targeted
/// cache invalidation via ``RenderCache/clearAffected(by:)``.
var identity: ViewIdentity?
/// The current value.
public var value: Value {
didSet {
RenderNotifier.renderCache?.clearAll()
if let identity {
RenderNotifier.renderCache?.clearAffected(by: identity)
} else {
RenderNotifier.renderCache?.clearAll()
}
RenderNotifier.current.setNeedsRender()
}
}
+77 -2
View File
@@ -292,8 +292,8 @@ struct RenderCacheTests {
@Test("Stats delta computes per-frame difference")
func statsDelta() {
let earlier = RenderCache.Stats(hits: 10, misses: 5, stores: 8, clears: 2)
let current = RenderCache.Stats(hits: 13, misses: 7, stores: 9, clears: 3)
let earlier = RenderCache.Stats(hits: 10, misses: 5, stores: 8, clears: 2, subtreeClears: 1)
let current = RenderCache.Stats(hits: 13, misses: 7, stores: 9, clears: 3, subtreeClears: 4)
let delta = current.delta(since: earlier)
@@ -301,6 +301,81 @@ struct RenderCacheTests {
#expect(delta.misses == 2)
#expect(delta.stores == 1)
#expect(delta.clears == 1)
#expect(delta.subtreeClears == 3)
#expect(delta.lookups == 5)
}
// MARK: - clearAffected(by:)
@Test("clearAffected clears ancestor cache entries")
func clearAffectedClearsAncestor() {
let cache = RenderCache()
let parent = ViewIdentity(path: "Root/VStack")
let child = ViewIdentity(path: "Root/VStack/Button")
cache.store(identity: parent, view: "parent", buffer: FrameBuffer(text: "p"), contextWidth: 80, contextHeight: 24)
cache.clearAffected(by: child)
let result = cache.lookup(identity: parent, view: "parent", contextWidth: 80, contextHeight: 24)
#expect(result == nil)
#expect(cache.stats.subtreeClears == 1)
}
@Test("clearAffected clears descendant cache entries")
func clearAffectedClearsDescendant() {
let cache = RenderCache()
let parent = ViewIdentity(path: "Root/VStack")
let child = ViewIdentity(path: "Root/VStack/Text")
cache.store(identity: child, view: "child", buffer: FrameBuffer(text: "c"), contextWidth: 80, contextHeight: 24)
cache.clearAffected(by: parent)
let result = cache.lookup(identity: child, view: "child", contextWidth: 80, contextHeight: 24)
#expect(result == nil)
}
@Test("clearAffected preserves sibling cache entries")
func clearAffectedPreservesSibling() {
let cache = RenderCache()
let sidebarID = ViewIdentity(path: "Root/Sidebar")
let contentID = ViewIdentity(path: "Root/Content")
let toggleID = ViewIdentity(path: "Root/Sidebar/Toggle")
cache.store(identity: sidebarID, view: "sidebar", buffer: FrameBuffer(text: "s"), contextWidth: 80, contextHeight: 24)
cache.store(identity: contentID, view: "content", buffer: FrameBuffer(text: "c"), contextWidth: 80, contextHeight: 24)
// State change in Sidebar/Toggle should NOT affect Content
cache.clearAffected(by: toggleID)
let sidebarResult = cache.lookup(identity: sidebarID, view: "sidebar", contextWidth: 80, contextHeight: 24)
let contentResult = cache.lookup(identity: contentID, view: "content", contextWidth: 80, contextHeight: 24)
#expect(sidebarResult == nil, "Sidebar is ancestor of Toggle, should be cleared")
#expect(contentResult != nil, "Content is sibling, should survive")
}
@Test("clearAffected clears exact identity match")
func clearAffectedClearsExactMatch() {
let cache = RenderCache()
let identity = ViewIdentity(path: "Root/MyView")
cache.store(identity: identity, view: 42, buffer: FrameBuffer(text: "x"), contextWidth: 80, contextHeight: 24)
cache.clearAffected(by: identity)
let result = cache.lookup(identity: identity, view: 42, contextWidth: 80, contextHeight: 24)
#expect(result == nil)
}
@Test("clearAffected on empty cache is a no-op")
func clearAffectedOnEmptyCacheIsNoop() {
let cache = RenderCache()
cache.clearAffected(by: ViewIdentity(path: "Root/Anything"))
#expect(cache.isEmpty)
#expect(cache.stats.subtreeClears == 1)
}
}