mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user