From d4f7d3aaf7a4791d2530642246a673a3f137e289 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 02:05:35 +0200 Subject: [PATCH 1/7] Test: Characterize effect loss across cache hits - EquatableEffectTests pins issue #14 end-to-end through the RenderLoop pipeline: lifecycle slots/tasks, key handlers, status-bar items, focus registrations, and nested cache entries all vanish across EquatableView cache hits (withKnownIssue markers) - Documents the first-frame hit source: the measurement pass stores into the live cache while effect sites are inert, so the same frame's main pass already hits and the subtree's effects never mount at all - Migrate the live-path marker test from RuntimeCharacterizationTests to the new FrameHarness end-to-end form --- Tests/TUIkitTests/EquatableEffectTests.swift | 200 ++++++++++++++++++ .../RuntimeCharacterizationTests.swift | 27 --- 2 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 Tests/TUIkitTests/EquatableEffectTests.swift diff --git a/Tests/TUIkitTests/EquatableEffectTests.swift b/Tests/TUIkitTests/EquatableEffectTests.swift new file mode 100644 index 00000000..6fe42e5e --- /dev/null +++ b/Tests/TUIkitTests/EquatableEffectTests.swift @@ -0,0 +1,200 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// EquatableEffectTests.swift +// +// License: MIT + +import Testing +import TUIkitTestSupport + +@testable import TUIkit + +/// Characterizes effect loss across `EquatableView` cache hits (issue #14), +/// end-to-end through the `RenderLoop` pipeline. +/// +/// On a cache hit only the wrapper identity and the subtree's state/ +/// observation records are kept alive; everything else a skipped subtree +/// contributed — lifecycle slots, tasks, key handlers, status-bar items, +/// focus registrations, nested cache entries — is dropped by the frame's +/// GC or simply never reaches the final pass's collectors. +/// +/// Two hit sources exist today: the first frame's measurement pass stores +/// entries into the live cache (effect sites are inert in `.measure`), so +/// the SAME frame's main pass already hits and never records the subtree's +/// effects; every later frame hits through unchanged `Equatable` content. +/// +/// Each test asserts the DESIRED behavior inside `withKnownIssue`; the +/// markers drop as the fix tasks of issue #14 land. +@MainActor +@Suite("EquatableView Effect Characterization", .serialized) +struct EquatableEffectTests { + + @Test("Cache hits keep lifecycle slots and tasks mounted") + func cacheHitKeepsLifecycleAndTask() { + let app = CachedLifecycleApp() + let harness = FrameHarness(app: app) + + harness.renderFrame() + harness.renderFrame() + + let events = app.trace.snapshot() + withKnownIssue("Issue #14: cache hits drop descendant lifecycle slots and cancel tasks") { + #expect(events.filter { $0 == "appear" }.count == 1) + #expect(events.contains("disappear") == false) + #expect(harness.tuiContext.lifecycle.taskCount == 1) + } matching: { issue in + guard case .expectationFailed = issue.kind else { return false } + return true + } + + harness.tuiContext.lifecycle.reset() + } + + @Test("Cache hits keep key handlers and status-bar items registered") + func cacheHitKeepsHandlersAndStatusBar() { + let harness = FrameHarness(app: CachedInputApp()) + + harness.renderFrame() + harness.renderFrame() + + withKnownIssue("Issue #14: a skipped subtree's key handler and status-bar declaration never reach the final collectors") { + #expect(harness.tuiContext.keyEventDispatcher.handlerCount == 1) + #expect(harness.tuiContext.statusBar.hasUserItems) + } matching: { issue in + guard case .expectationFailed = issue.kind else { return false } + return true + } + } + + @Test("Cache hits keep focus registrations alive") + func cacheHitKeepsFocus() { + let harness = FrameHarness(app: CachedFocusApp()) + + harness.renderFrame() + harness.renderFrame() + + withKnownIssue("Issue #14: a skipped subtree's focusable never reaches the staged focus registrations") { + #expect(harness.tuiContext.focusManager.currentFocusedID != nil) + } matching: { issue in + guard case .expectationFailed = issue.kind else { return false } + return true + } + } + + @Test("Outer cache hits keep nested cache entries alive") + func cacheHitKeepsNestedEntries() { + let harness = FrameHarness(app: NestedCacheApp()) + + harness.renderFrame() + harness.renderFrame() + + withKnownIssue("Issue #14: nested cache entries are garbage-collected on the outer hit") { + #expect(harness.tuiContext.renderCache.count == 2) + } matching: { issue in + guard case .expectationFailed = issue.kind else { return false } + return true + } + } +} + +// MARK: - Fixtures + +/// Content whose `==` is always true, so every lookup after the first store +/// is a guaranteed cache hit regardless of captured references. +private struct CachedLifecycleContent: View, Equatable { + let trace: TraceRecorder + + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { true } + + var body: some View { + Text("cached") + .onAppear { trace.record("appear") } + .onDisappear { trace.record("disappear") } + .task { + // Runs until cancellation so `lifecycle.taskCount` observes + // whether the mount survived the frame. + try? await Task.sleep(nanoseconds: 60_000_000_000) + } + } +} + +private struct CachedLifecycleApp: App { + let trace = TraceRecorder() + + init() {} + + var body: some Scene { + WindowGroup { + CachedLifecycleContent(trace: trace).equatable() + } + } +} + +/// Declares a key handler and a status-bar item inside cacheable content. +private struct CachedInputContent: View, Equatable { + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { true } + + var body: some View { + Text("input") + .onKeyPress(.enter) {} + .statusBarItems([StatusBarItem(shortcut: "x", label: "cached")]) + } +} + +private struct CachedInputApp: App { + init() {} + + var body: some Scene { + WindowGroup { + CachedInputContent().equatable() + } + } +} + +/// Places a focusable control inside cacheable content. +private struct CachedFocusContent: View, Equatable { + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { true } + + var body: some View { + Button("Press") {} + } +} + +private struct CachedFocusApp: App { + init() {} + + var body: some Scene { + WindowGroup { + CachedFocusContent().equatable() + } + } +} + +/// Effect-free inner content cached below an effect-free cached outer view. +private struct InnerCachedContent: View, Equatable { + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { true } + + var body: some View { + Text("inner") + } +} + +private struct OuterCachedContent: View, Equatable { + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { true } + + var body: some View { + VStack { + Text("outer") + InnerCachedContent().equatable() + } + } +} + +private struct NestedCacheApp: App { + init() {} + + var body: some Scene { + WindowGroup { + OuterCachedContent().equatable() + } + } +} diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index 491a7246..dab651f8 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -224,33 +224,6 @@ struct RuntimeCharacterizationTests { #expect(firstContext.renderCache !== secondContext.renderCache) } - @Test("Cached descendant lifecycle loss remains characterized for issue #14") - func knownCachedDescendantLifecycleDefect() { - let harness = RuntimeCharacterizationHarness() - let trace = harness.trace - - _ = harness.render { - EquatableView( - content: FixedLifecycleView(token: "cached", trace: trace) - ) - } - _ = harness.render { - EquatableView( - content: FixedLifecycleView(token: "cached", trace: trace) - ) - } - - let disappearanceCount = trace.snapshot().filter { - $0 == .lifecycle("disappear:cached") - }.count - - withKnownIssue("Issue #14: a cache hit does not keep descendant lifecycle tokens active") { - #expect(disappearanceCount == 0) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } - } } // MARK: - Fixtures From 1053ce852677f18ad4427d43cc876ee06b83c737 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 02:06:03 +0200 Subject: [PATCH 2/7] Chore: Drop a stray blank line left by the marker-test migration --- Tests/TUIkitTests/RuntimeCharacterizationTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index dab651f8..74c1b04c 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -223,7 +223,6 @@ struct RuntimeCharacterizationTests { #expect(firstContext.renderCache !== secondContext.renderCache) } - } // MARK: - Fixtures From 3b6f99d4223dfcc677adb0f66e6429af9050c302 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 02:15:07 +0200 Subject: [PATCH 3/7] Fix: Bypass subtree caching for effect-bearing content - EquatableView classifies its content on every cache miss: the pass's effect-registration probe is snapshotted around the rendering, and any delta flags the identity in RenderCache so lookups always miss and the subtree renders every frame - The probe (package env key, installed by RenderLoop.passEnvironment) sums all per-pass effect sinks: key handlers, preference writes, status-bar declarations, staged focus registrations, and deferred lifetime-effect records; new counters: PreferenceStorage.writeCount, StatusBarState.passRegistrationCount, FocusManager.stagedRegistrationCount - Measurement passes stay out of the cache entirely: a buffer stored in .measure let the same frame's output pass hit a subtree whose effects were never recorded (they never mounted at all) - Live path (no collectors) keeps its historical caching semantics - EquatableEffectTests lifecycle/handler/status-bar/focus markers flip green and are dropped; the nested-entry marker stays for Task 3 --- Sources/TUIkit/App/RenderLoop.swift | 17 +++++ Sources/TUIkit/Focus/Focus.swift | 9 +++ Sources/TUIkit/StatusBar/StatusBarState.swift | 6 ++ .../Environment/PreferenceKey.swift | 9 +++ Sources/TUIkitView/Core/EquatableView.swift | 70 +++++++++++++++---- .../Rendering/EffectRegistrationProbe.swift | 35 ++++++++++ .../TUIkitView/Rendering/RenderCache.swift | 47 +++++++++++++ Tests/TUIkitTests/EquatableEffectTests.swift | 51 +++++--------- 8 files changed, 196 insertions(+), 48 deletions(-) create mode 100644 Sources/TUIkitView/Rendering/EffectRegistrationProbe.swift diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index 31681e50..7d83b113 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -349,6 +349,11 @@ extension RenderLoop { /// All other services (state storage, lifecycle, focus queries, palette, /// …) stay on the live runtime. /// + /// The environment also carries the pass's effect-registration probe: + /// a closure summing every effect sink of this pass, which + /// `EquatableView` snapshots around a cache-miss rendering to decide + /// whether the subtree is effect-free and safe to memoize. + /// /// - Parameters: /// - base: The frame's live environment. /// - collectors: The scratch collectors of the current pass. @@ -363,6 +368,18 @@ extension RenderLoop { environment.statusBar = collectors.statusBar environment.appHeader = collectors.appHeader environment.pendingFrameEffects = collectors.pendingEffects + let keyEventDispatcher = collectors.keyEventDispatcher + let preferences = collectors.preferences + let statusBar = collectors.statusBar + let pendingEffects = collectors.pendingEffects + let focusManager = focusManager + environment.effectRegistrationProbe = { + keyEventDispatcher.handlerCount + + preferences.writeCount + + statusBar.passRegistrationCount + + pendingEffects.deferredEffectCount + + focusManager.stagedRegistrationCount + } return environment } } diff --git a/Sources/TUIkit/Focus/Focus.swift b/Sources/TUIkit/Focus/Focus.swift index 36931000..71da9abf 100644 --- a/Sources/TUIkit/Focus/Focus.swift +++ b/Sources/TUIkit/Focus/Focus.swift @@ -87,6 +87,15 @@ public final class FocusManager: @unchecked Sendable { /// semantics. private var isCollectingPass = false + /// Number of focusables staged by the current collecting pass. + /// + /// Snapshot around a subtree rendering to detect focus registrations + /// (see `EnvironmentValues.effectRegistrationProbe`). Zero outside a + /// collecting pass. + var stagedRegistrationCount: Int { + stagedSections.reduce(0) { $0 + $1.focusables.count } + } + /// Creates a new focus manager instance. public init() {} diff --git a/Sources/TUIkit/StatusBar/StatusBarState.swift b/Sources/TUIkit/StatusBar/StatusBarState.swift index 64d082f9..21612041 100644 --- a/Sources/TUIkit/StatusBar/StatusBarState.swift +++ b/Sources/TUIkit/StatusBar/StatusBarState.swift @@ -81,6 +81,12 @@ public final class StatusBarState: @unchecked Sendable { /// Declarative registrations collected during the current render pass. private(set) var passRegistrations: [PassRegistration] = [] + /// Number of declarative registrations recorded by the current pass. + /// + /// Snapshot around a subtree rendering to detect status-bar + /// declarations (see `EnvironmentValues.effectRegistrationProbe`). + var passRegistrationCount: Int { passRegistrations.count } + /// The focus manager used to determine the active section. /// /// Set by `RenderLoop` at the start of each render pass. diff --git a/Sources/TUIkitCore/Environment/PreferenceKey.swift b/Sources/TUIkitCore/Environment/PreferenceKey.swift index bef6bc1d..250498d2 100644 --- a/Sources/TUIkitCore/Environment/PreferenceKey.swift +++ b/Sources/TUIkitCore/Environment/PreferenceKey.swift @@ -98,6 +98,14 @@ public final class PreferenceStorage: @unchecked Sendable { /// Stack of preference values for nested rendering. private var stack: [PreferenceValues] = [PreferenceValues()] + /// Monotonic count of preference writes over this storage's lifetime. + /// + /// Never reset between passes: per-pass scratch storages start at zero, + /// so snapshotting the counter around a subtree rendering reveals + /// whether that subtree declared any preference (see + /// `EnvironmentValues.effectRegistrationProbe`). + package private(set) var writeCount = 0 + /// Creates a new preference storage. public init() {} @@ -140,6 +148,7 @@ public extension PreferenceStorage { /// Sets a preference value. func setValue(_ value: K.Value, forKey key: K.Type) { + writeCount += 1 var currentValues = current K.reduce(value: ¤tValues[key]) { value } current = currentValues diff --git a/Sources/TUIkitView/Core/EquatableView.swift b/Sources/TUIkitView/Core/EquatableView.swift index dd7ccb71..5a03515c 100644 --- a/Sources/TUIkitView/Core/EquatableView.swift +++ b/Sources/TUIkitView/Core/EquatableView.swift @@ -50,17 +50,39 @@ 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. +/// +/// ## Effect Bypass +/// +/// Content that registers per-pass effects while rendering — key handlers, +/// focus registrations (Button, Toggle, …), status-bar declarations, +/// preference writes, or lifetime-effect records (`onAppear`, `.task`, …) — +/// must reach the frame's collectors on EVERY frame; serving it from a +/// cache would silently drop those registrations at the frame commit. +/// +/// Inside `RenderLoop` frames the wrapper therefore classifies its content +/// on every cache miss: it snapshots the pass's effect-registration probe +/// around the rendering, and any delta flags the identity as effect-bearing +/// in the ``RenderCache``. Flagged identities never produce hits, so the +/// subtree renders each frame and behaves exactly as if it were unwrapped +/// (including pulse-animated focus indicators). Only provably effect-free +/// output is ever served from the cache, making `.equatable()` safe to +/// apply anywhere — on effect-bearing subtrees it simply has no effect. +/// +/// Measurement passes (`RenderPhase.measure`) stay out of the cache +/// entirely: effect sites are inert there, so a buffer stored during +/// sizing would let the same frame's output pass hit a subtree whose +/// effects were never recorded, and any classification would measure an +/// inert traversal. +/// +/// On the live path (no `RenderLoop`, e.g. `ViewRenderer` or test +/// harnesses) caching keeps its historical semantics without +/// classification. /// /// ## Cache Invalidation /// /// 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: View { @@ -94,7 +116,17 @@ extension EquatableView: Renderable { cache.markActive(identity) } - // Cache hit: view unchanged and context size matches + // Measurement passes stay out of the cache entirely: effect sites + // are inert in `.measure`, so a buffer stored here would let the + // same frame's output pass hit a subtree whose effects were never + // recorded, and the delta classification below would measure an + // inert traversal. + if context.phase == .measure { + return TUIkitView.renderToBuffer(content, context: context) + } + + // Cache hit: view unchanged, size matches, and the identity is + // classified effect-free (effect-bearing identities never hit). if let cached = cache.lookup( identity: identity, view: content, @@ -108,16 +140,26 @@ extension EquatableView: Renderable { return cached } - // Cache miss: render normally and store result + // Cache miss: render and classify. Any effect registration during + // this rendering flags the identity — an effect-bearing subtree + // must render every frame so its registrations reach the frame's + // collectors. Only provably effect-free output is stored. Without + // a probe (live path) the historical semantics apply: always store. + let probe = context.environment.effectRegistrationProbe + let registrationsBeforeRender = probe?() ?? 0 let buffer = TUIkitView.renderToBuffer(content, context: context) + let carriesEffects = probe.map { $0() != registrationsBeforeRender } ?? false - cache.store( - identity: identity, - view: content, - buffer: buffer, - contextWidth: context.availableWidth, - contextHeight: context.availableHeight - ) + cache.setCarriesEffects(carriesEffects, for: identity) + if !carriesEffects { + cache.store( + identity: identity, + view: content, + buffer: buffer, + contextWidth: context.availableWidth, + contextHeight: context.availableHeight + ) + } return buffer } diff --git a/Sources/TUIkitView/Rendering/EffectRegistrationProbe.swift b/Sources/TUIkitView/Rendering/EffectRegistrationProbe.swift new file mode 100644 index 00000000..395660a8 --- /dev/null +++ b/Sources/TUIkitView/Rendering/EffectRegistrationProbe.swift @@ -0,0 +1,35 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// EffectRegistrationProbe.swift +// +// License: MIT + +import TUIkitCore + +// MARK: - Effect Registration Probe + +/// Environment key for the per-pass effect-registration probe. +private struct EffectRegistrationProbeKey: EnvironmentKey { + static let defaultValue: (@Sendable () -> Int)? = nil +} + +extension EnvironmentValues { + /// Counts the effect registrations collected by the current render pass. + /// + /// Installed per pass by the render loop, the closure sums every + /// per-pass effect sink: key handlers, preference writes, status-bar + /// declarations, staged focus registrations, and deferred + /// lifetime-effect records. The absolute value is meaningless; callers + /// snapshot it around a subtree rendering and compare — any delta means + /// the subtree registered at least one effect during that rendering. + /// + /// ``EquatableView`` uses this to classify content on a cache miss: + /// only subtrees that render without a delta are provably effect-free + /// and safe to memoize. + /// + /// `nil` outside `RenderLoop` frames (standalone `ViewRenderer`, test + /// harnesses), where caching keeps its historical live-path semantics. + package var effectRegistrationProbe: (@Sendable () -> Int)? { + get { self[EffectRegistrationProbeKey.self] } + set { self[EffectRegistrationProbeKey.self] = newValue } + } +} diff --git a/Sources/TUIkitView/Rendering/RenderCache.swift b/Sources/TUIkitView/Rendering/RenderCache.swift index b29b8466..8ce03bcb 100644 --- a/Sources/TUIkitView/Rendering/RenderCache.swift +++ b/Sources/TUIkitView/Rendering/RenderCache.swift @@ -136,6 +136,14 @@ public final class RenderCache: @unchecked Sendable { /// Cached entries keyed by view identity. private var entries: [ViewIdentity: CacheEntry] = [:] + /// Identities whose content registered per-pass effects while rendering. + /// + /// Flagged identities never produce cache hits: their subtree must + /// render every frame so its effect registrations reach the frame's + /// collectors. The classification refreshes on every miss rendering + /// and is garbage-collected with the identity. + private var effectBearingIdentities: Set = [] + /// Identities seen during the current render pass (for garbage collection). private var activeIdentities: Set = [] @@ -181,6 +189,11 @@ extension RenderCache { contextWidth: Int, contextHeight: Int ) -> FrameBuffer? { + guard !effectBearingIdentities.contains(identity) else { + stats.misses += 1 + logDebug("MISS (carries effects) \(identity.path)") + return nil + } guard let entry = entries[identity] else { stats.misses += 1 logDebug("MISS (no entry) \(identity.path)") @@ -244,6 +257,36 @@ extension RenderCache { activeIdentities.insert(identity) } + /// Classifies whether an identity's content registered per-pass effects + /// while rendering. + /// + /// Flagging drops any stored buffer and makes every future ``lookup`` + /// miss, so the subtree renders each frame and its registrations reach + /// the frame's collectors. Unflagging (content re-classified as + /// effect-free) re-enables normal caching; the caller stores the fresh + /// buffer in the same rendering. + /// + /// - Parameters: + /// - carriesEffects: Whether the content registered effects during + /// its most recent miss rendering. + /// - identity: The view's structural identity. + package func setCarriesEffects(_ carriesEffects: Bool, for identity: ViewIdentity) { + if carriesEffects { + effectBearingIdentities.insert(identity) + entries.removeValue(forKey: identity) + } else { + effectBearingIdentities.remove(identity) + } + } + + /// Whether the identity is currently classified as effect-bearing. + /// + /// - Parameter identity: The view's structural identity. + /// - Returns: True when ``lookup`` bypasses the cache for this identity. + package func carriesEffects(_ identity: ViewIdentity) -> Bool { + effectBearingIdentities.contains(identity) + } + /// Begins a new render pass by clearing the active identity set /// and snapshotting the current stats for per-frame delta calculation. public func beginRenderPass() { @@ -260,6 +303,9 @@ extension RenderCache { for key in staleKeys { entries.removeValue(forKey: key) } + effectBearingIdentities = effectBearingIdentities.filter { + activeIdentities.contains($0) + } } /// Clears all cached entries. @@ -297,6 +343,7 @@ extension RenderCache { /// Removes all cached entries, resets GC state, and clears statistics. public func reset() { entries.removeAll() + effectBearingIdentities.removeAll() activeIdentities.removeAll() stats = Stats() statsAtFrameStart = Stats() diff --git a/Tests/TUIkitTests/EquatableEffectTests.swift b/Tests/TUIkitTests/EquatableEffectTests.swift index 6fe42e5e..429b3fc1 100644 --- a/Tests/TUIkitTests/EquatableEffectTests.swift +++ b/Tests/TUIkitTests/EquatableEffectTests.swift @@ -8,22 +8,20 @@ import TUIkitTestSupport @testable import TUIkit -/// Characterizes effect loss across `EquatableView` cache hits (issue #14), +/// Specifies effect behavior across `EquatableView` cache hits (issue #14), /// end-to-end through the `RenderLoop` pipeline. /// -/// On a cache hit only the wrapper identity and the subtree's state/ -/// observation records are kept alive; everything else a skipped subtree -/// contributed — lifecycle slots, tasks, key handlers, status-bar items, -/// focus registrations, nested cache entries — is dropped by the frame's -/// GC or simply never reaches the final pass's collectors. +/// Effect-bearing content is detected on every cache miss via the pass's +/// effect-registration probe and bypasses the cache entirely, so lifecycle +/// slots, tasks, key handlers, status-bar items, and focus registrations +/// reach the frame's collectors on every frame. Measurement passes stay +/// out of the cache so classification never measures an inert traversal +/// and the first frame's output pass cannot hit a sizing buffer. /// -/// Two hit sources exist today: the first frame's measurement pass stores -/// entries into the live cache (effect sites are inert in `.measure`), so -/// the SAME frame's main pass already hits and never records the subtree's -/// effects; every later frame hits through unchanged `Equatable` content. -/// -/// Each test asserts the DESIRED behavior inside `withKnownIssue`; the -/// markers drop as the fix tasks of issue #14 land. +/// Remaining known issue (Task 3 of issue #14): nested cache entries below +/// an effect-free cached root are still garbage-collected on the outer +/// hit; that test keeps its `withKnownIssue` marker until the subtree +/// liveness lands. @MainActor @Suite("EquatableView Effect Characterization", .serialized) struct EquatableEffectTests { @@ -37,14 +35,9 @@ struct EquatableEffectTests { harness.renderFrame() let events = app.trace.snapshot() - withKnownIssue("Issue #14: cache hits drop descendant lifecycle slots and cancel tasks") { - #expect(events.filter { $0 == "appear" }.count == 1) - #expect(events.contains("disappear") == false) - #expect(harness.tuiContext.lifecycle.taskCount == 1) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } + #expect(events.filter { $0 == "appear" }.count == 1) + #expect(events.contains("disappear") == false) + #expect(harness.tuiContext.lifecycle.taskCount == 1) harness.tuiContext.lifecycle.reset() } @@ -56,13 +49,8 @@ struct EquatableEffectTests { harness.renderFrame() harness.renderFrame() - withKnownIssue("Issue #14: a skipped subtree's key handler and status-bar declaration never reach the final collectors") { - #expect(harness.tuiContext.keyEventDispatcher.handlerCount == 1) - #expect(harness.tuiContext.statusBar.hasUserItems) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } + #expect(harness.tuiContext.keyEventDispatcher.handlerCount == 1) + #expect(harness.tuiContext.statusBar.hasUserItems) } @Test("Cache hits keep focus registrations alive") @@ -72,12 +60,7 @@ struct EquatableEffectTests { harness.renderFrame() harness.renderFrame() - withKnownIssue("Issue #14: a skipped subtree's focusable never reaches the staged focus registrations") { - #expect(harness.tuiContext.focusManager.currentFocusedID != nil) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } + #expect(harness.tuiContext.focusManager.currentFocusedID != nil) } @Test("Outer cache hits keep nested cache entries alive") From e84738728c6d12995f31ea681567fdbe272ab0b1 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 02:16:39 +0200 Subject: [PATCH 4/7] Fix: Keep nested cache entries alive across outer hits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RenderCache.markSubtreeActive keeps every entry at or below a cached subtree root active without traversal (ancestor logic like clearAffected(by:)) - TUIContext.applyFrameLiveness marks subtree roots via markSubtreeActive instead of markActive; the EquatableView live path gains the same call - The nested-entry characterization marker flips green and is dropped — EquatableEffectTests now asserts all issue #14 effect contracts directly --- Sources/TUIkit/Environment/TUIContext.swift | 2 +- Sources/TUIkitView/Core/EquatableView.swift | 6 ++++-- Sources/TUIkitView/Rendering/RenderCache.swift | 15 +++++++++++++++ Tests/TUIkitTests/EquatableEffectTests.swift | 16 ++++------------ 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/Sources/TUIkit/Environment/TUIContext.swift b/Sources/TUIkit/Environment/TUIContext.swift index 3bc4d2fb..a1493b33 100644 --- a/Sources/TUIkit/Environment/TUIContext.swift +++ b/Sources/TUIkit/Environment/TUIContext.swift @@ -500,7 +500,7 @@ extension TUIContext { for root in pendingEffects.activeSubtreeRoots { stateStorage.markSubtreeActive(root) observationRegistry.markSubtreeActive(root) - renderCache.markActive(root) + renderCache.markSubtreeActive(root) } } diff --git a/Sources/TUIkitView/Core/EquatableView.swift b/Sources/TUIkitView/Core/EquatableView.swift index 5a03515c..4963ab86 100644 --- a/Sources/TUIkitView/Core/EquatableView.swift +++ b/Sources/TUIkitView/Core/EquatableView.swift @@ -171,14 +171,16 @@ private extension EquatableView { /// Marks the content's runtime records as active for end-of-pass cleanup. /// /// When returning a cached buffer, the subtree's views aren't visited. - /// Their state and Observation identities must still be marked active — - /// per pass inside a RenderLoop frame, directly on the live path. + /// Their state, Observation, and nested cache identities must still be + /// marked active — per pass inside a RenderLoop frame, directly on the + /// live path. func markSubtreeActive(context: RenderContext) { if let pendingEffects = context.environment.pendingFrameEffects { pendingEffects.markSubtreeActive(context.identity) } else { context.environment.stateStorage!.markSubtreeActive(context.identity) context.environment.observationRegistry?.markSubtreeActive(context.identity) + context.environment.renderCache!.markSubtreeActive(context.identity) } } } diff --git a/Sources/TUIkitView/Rendering/RenderCache.swift b/Sources/TUIkitView/Rendering/RenderCache.swift index 8ce03bcb..c6260ec4 100644 --- a/Sources/TUIkitView/Rendering/RenderCache.swift +++ b/Sources/TUIkitView/Rendering/RenderCache.swift @@ -257,6 +257,21 @@ extension RenderCache { activeIdentities.insert(identity) } + /// Keeps cache entries below a cached subtree root active without + /// traversing the subtree. + /// + /// On an `EquatableView` cache hit the subtree is skipped, so nested + /// entries never mark themselves active and would be swept by + /// ``removeInactive()``. Mirrors `StateStorage.markSubtreeActive`: + /// every entry at or below the root survives the frame's GC. + /// + /// - Parameter root: The cached subtree's root identity. + package func markSubtreeActive(_ root: ViewIdentity) { + activeIdentities.formUnion(entries.keys.filter { identity in + identity == root || root.isAncestor(of: identity) + }) + } + /// Classifies whether an identity's content registered per-pass effects /// while rendering. /// diff --git a/Tests/TUIkitTests/EquatableEffectTests.swift b/Tests/TUIkitTests/EquatableEffectTests.swift index 429b3fc1..1e844a72 100644 --- a/Tests/TUIkitTests/EquatableEffectTests.swift +++ b/Tests/TUIkitTests/EquatableEffectTests.swift @@ -16,12 +16,9 @@ import TUIkitTestSupport /// slots, tasks, key handlers, status-bar items, and focus registrations /// reach the frame's collectors on every frame. Measurement passes stay /// out of the cache so classification never measures an inert traversal -/// and the first frame's output pass cannot hit a sizing buffer. -/// -/// Remaining known issue (Task 3 of issue #14): nested cache entries below -/// an effect-free cached root are still garbage-collected on the outer -/// hit; that test keeps its `withKnownIssue` marker until the subtree -/// liveness lands. +/// and the first frame's output pass cannot hit a sizing buffer. Nested +/// cache entries below an effect-free cached root survive outer hits via +/// `RenderCache.markSubtreeActive`. @MainActor @Suite("EquatableView Effect Characterization", .serialized) struct EquatableEffectTests { @@ -70,12 +67,7 @@ struct EquatableEffectTests { harness.renderFrame() harness.renderFrame() - withKnownIssue("Issue #14: nested cache entries are garbage-collected on the outer hit") { - #expect(harness.tuiContext.renderCache.count == 2) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } + #expect(harness.tuiContext.renderCache.count == 2) } } From c867446f5693ea2d2e9c07f0694c83917648ed98 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 02:21:42 +0200 Subject: [PATCH 5/7] Fix: Count staged sections in the focus registration probe - A focus section declared without focusables inside cacheable content produced no probe delta, so the subtree was classified effect-free and the section vanished from committed Tab cycling on cache hits - stagedRegistrationCount now counts staged sections plus focusables; new end-to-end test pins the section survival --- Sources/TUIkit/Focus/Focus.swift | 9 ++++-- Tests/TUIkitTests/EquatableEffectTests.swift | 29 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Sources/TUIkit/Focus/Focus.swift b/Sources/TUIkit/Focus/Focus.swift index 71da9abf..0c73ebdb 100644 --- a/Sources/TUIkit/Focus/Focus.swift +++ b/Sources/TUIkit/Focus/Focus.swift @@ -87,13 +87,16 @@ public final class FocusManager: @unchecked Sendable { /// semantics. private var isCollectingPass = false - /// Number of focusables staged by the current collecting pass. + /// Number of sections and focusables staged by the current collecting + /// pass. /// /// Snapshot around a subtree rendering to detect focus registrations - /// (see `EnvironmentValues.effectRegistrationProbe`). Zero outside a + /// (see `EnvironmentValues.effectRegistrationProbe`). Sections count + /// even without focusables: an empty section still affects committed + /// Tab cycling and must reach the commit every frame. Zero outside a /// collecting pass. var stagedRegistrationCount: Int { - stagedSections.reduce(0) { $0 + $1.focusables.count } + stagedSections.reduce(stagedSections.count) { $0 + $1.focusables.count } } /// Creates a new focus manager instance. diff --git a/Tests/TUIkitTests/EquatableEffectTests.swift b/Tests/TUIkitTests/EquatableEffectTests.swift index 1e844a72..a08012eb 100644 --- a/Tests/TUIkitTests/EquatableEffectTests.swift +++ b/Tests/TUIkitTests/EquatableEffectTests.swift @@ -60,6 +60,16 @@ struct EquatableEffectTests { #expect(harness.tuiContext.focusManager.currentFocusedID != nil) } + @Test("Cache hits keep declared focus sections registered") + func cacheHitKeepsFocusSections() { + let harness = FrameHarness(app: CachedSectionApp()) + + harness.renderFrame() + harness.renderFrame() + + #expect(harness.tuiContext.focusManager.sectionIDs.contains("cached-section")) + } + @Test("Outer cache hits keep nested cache entries alive") func cacheHitKeepsNestedEntries() { let harness = FrameHarness(app: NestedCacheApp()) @@ -144,6 +154,25 @@ private struct CachedFocusApp: App { } } +/// Declares a focus section (without focusables) inside cacheable content. +private struct CachedSectionContent: View, Equatable { + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { true } + + var body: some View { + Text("sectioned").focusSection("cached-section") + } +} + +private struct CachedSectionApp: App { + init() {} + + var body: some Scene { + WindowGroup { + CachedSectionContent().equatable() + } + } +} + /// Effect-free inner content cached below an effect-free cached outer view. private struct InnerCachedContent: View, Equatable { nonisolated static func == (lhs: Self, rhs: Self) -> Bool { true } From c48e35d78de2d07e9a904489b41f827954d69769 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 02:24:59 +0200 Subject: [PATCH 6/7] Fix: Include style environment in cache invalidation - EnvironmentFingerprint (type-erased Equatable snapshot, TUIkitView) joins identity, view value, and size in the RenderCache key; a mismatch misses so a style change above an .equatable() boundary can never serve a stale buffer - RenderLoop installs the per-pass fingerprint probe snapshotting the issue #14 audit members: foregroundStyle and focusIndicatorColor (pulse-derived, so cached entries inside an active focus section refresh every tick); palette/appearance stay covered by the global EnvironmentSnapshot clear, pulsePhase itself stays excluded - Live path stores and matches nil fingerprints, keeping its historical cache key - New end-to-end test: a foregroundStyle change between frames misses and re-stores, an unchanged style hits again --- Sources/TUIkit/App/RenderLoop.swift | 31 +++++++ Sources/TUIkitView/Core/EquatableView.swift | 17 +++- .../Rendering/EnvironmentFingerprint.swift | 67 ++++++++++++++ .../TUIkitView/Rendering/RenderCache.swift | 92 ++++++++++++++++++- Tests/TUIkitTests/EquatableEffectTests.swift | 54 +++++++++++ 5 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 Sources/TUIkitView/Rendering/EnvironmentFingerprint.swift diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index 7d83b113..2592b456 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -30,6 +30,34 @@ private struct EnvironmentSnapshot: Equatable { } } +/// Per-subtree render-affecting environment values included in cache keys. +/// +/// The subtree analog of ``EnvironmentSnapshot``'s global palette/appearance +/// clear: these values change a cached subtree's visual output without +/// changing its `Equatable` view value, so `EquatableView` snapshots them +/// per position and `RenderCache` misses on mismatch. +/// +/// Members come from the issue #14 audit: `foregroundStyle` colors any text +/// below the wrapper; `focusIndicatorColor` drives the pulsing border +/// indicator inside an active focus section (pulse-derived, so entries +/// inside an active section refresh every tick instead of freezing). +/// Everything else read by renderables is either effect-bearing content +/// (bypassed by classification), globally snapshotted, or deliberately +/// excluded (`pulsePhase` itself). +private struct StyleEnvironmentFingerprint: Equatable, Sendable { + /// The foreground color set via `.foregroundStyle(_:)`. + let foregroundStyle: Color? + + /// The pulse-lerped focus indicator color of the enclosing section. + let focusIndicatorColor: Color? + + /// Snapshots the fingerprint members from an environment. + init(from environment: EnvironmentValues) { + self.foregroundStyle = environment.foregroundStyle + self.focusIndicatorColor = environment.focusIndicatorColor + } +} + /// ANSI background codes for each render surface in a frame. /// /// Keeping these grouped avoids accidentally rendering every surface @@ -380,6 +408,9 @@ extension RenderLoop { + pendingEffects.deferredEffectCount + focusManager.stagedRegistrationCount } + environment.environmentFingerprintProbe = { environment in + EnvironmentFingerprint(StyleEnvironmentFingerprint(from: environment)) + } return environment } } diff --git a/Sources/TUIkitView/Core/EquatableView.swift b/Sources/TUIkitView/Core/EquatableView.swift index 4963ab86..71f3bf64 100644 --- a/Sources/TUIkitView/Core/EquatableView.swift +++ b/Sources/TUIkitView/Core/EquatableView.swift @@ -125,13 +125,21 @@ extension EquatableView: Renderable { return TUIkitView.renderToBuffer(content, context: context) } - // Cache hit: view unchanged, size matches, and the identity is - // classified effect-free (effect-bearing identities never hit). + // Fingerprint of the render-affecting environment at this position + // (foreground style, focus indicator, …). Part of the cache key so + // an environment change above the wrapper can never serve a stale + // buffer. `nil` on the live path. + let environmentFingerprint = context.environment.environmentFingerprintProbe?(context.environment) + + // Cache hit: view unchanged, size and environment match, and the + // identity is classified effect-free (effect-bearing identities + // never hit). if let cached = cache.lookup( identity: identity, view: content, contextWidth: context.availableWidth, - contextHeight: context.availableHeight + contextHeight: context.availableHeight, + environmentFingerprint: environmentFingerprint ) { // Still need to keep runtime records inside the cached subtree // active even though its body is not evaluated. @@ -157,7 +165,8 @@ extension EquatableView: Renderable { view: content, buffer: buffer, contextWidth: context.availableWidth, - contextHeight: context.availableHeight + contextHeight: context.availableHeight, + environmentFingerprint: environmentFingerprint ) } diff --git a/Sources/TUIkitView/Rendering/EnvironmentFingerprint.swift b/Sources/TUIkitView/Rendering/EnvironmentFingerprint.swift new file mode 100644 index 00000000..111f6c0f --- /dev/null +++ b/Sources/TUIkitView/Rendering/EnvironmentFingerprint.swift @@ -0,0 +1,67 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// EnvironmentFingerprint.swift +// +// License: MIT + +import TUIkitCore + +// MARK: - Environment Fingerprint + +/// A type-erased, equatable snapshot of render-affecting environment values +/// at an ``EquatableView``'s tree position. +/// +/// The render loop knows which environment keys change a subtree's visual +/// output without changing the `Equatable` view value (foreground style, +/// focus indicator color, …). It snapshots them into this opaque value via +/// ``TUIkitCore/EnvironmentValues/environmentFingerprintProbe``, and +/// ``RenderCache`` includes the fingerprint in its lookup: a mismatch +/// misses, so a style change above a cache boundary can never serve a +/// stale buffer. +/// +/// Two fingerprints are equal when they wrap equal snapshots of the same +/// underlying type. The concrete snapshot type stays private to the layer +/// that installed the probe. +package struct EnvironmentFingerprint: Equatable { + /// The type-erased snapshot value. + private let value: Any + + /// Compares another erased snapshot against this one's typed value. + private let equals: @Sendable (Any) -> Bool + + /// Wraps a typed environment snapshot. + /// + /// - Parameter value: The snapshot to erase; equality of fingerprints + /// is the equality of these snapshots. + package init(_ value: Value) { + self.value = value + self.equals = { ($0 as? Value) == value } + } + + package static func == (lhs: Self, rhs: Self) -> Bool { + lhs.equals(rhs.value) + } +} + +// MARK: - Environment Key + +/// Environment key for the per-pass fingerprint probe. +private struct EnvironmentFingerprintProbeKey: EnvironmentKey { + static let defaultValue: (@Sendable (EnvironmentValues) -> EnvironmentFingerprint)? = nil +} + +extension EnvironmentValues { + /// Snapshots the render-affecting environment values at a cache + /// boundary. + /// + /// Installed per pass by the render loop. ``EquatableView`` passes its + /// own environment in and hands the resulting fingerprint to + /// ``RenderCache`` as part of the cache key, so environment-driven + /// output changes invalidate the affected entry. + /// + /// `nil` outside `RenderLoop` frames (live path), where cache lookups + /// keep their historical key of identity, view value, and size. + package var environmentFingerprintProbe: (@Sendable (EnvironmentValues) -> EnvironmentFingerprint)? { + get { self[EnvironmentFingerprintProbeKey.self] } + set { self[EnvironmentFingerprintProbeKey.self] = newValue } + } +} diff --git a/Sources/TUIkitView/Rendering/RenderCache.swift b/Sources/TUIkitView/Rendering/RenderCache.swift index c6260ec4..d2d73128 100644 --- a/Sources/TUIkitView/Rendering/RenderCache.swift +++ b/Sources/TUIkitView/Rendering/RenderCache.swift @@ -124,12 +124,38 @@ public final class RenderCache: @unchecked Sendable { /// The available height when this entry was cached. public let contextHeight: Int - /// Creates a new cache entry. + /// The environment fingerprint captured at store time. + /// + /// Part of the cache key: a lookup with a differing fingerprint + /// misses so environment-driven output changes (foreground style, + /// focus indicator) never serve a stale buffer. `nil` for entries + /// stored on the live path, which only match `nil` lookups. + package let environmentFingerprint: EnvironmentFingerprint? + + /// Creates a new cache entry without an environment fingerprint. public init(viewSnapshot: Any, buffer: FrameBuffer, contextWidth: Int, contextHeight: Int) { + self.init( + viewSnapshot: viewSnapshot, + buffer: buffer, + contextWidth: contextWidth, + contextHeight: contextHeight, + environmentFingerprint: nil + ) + } + + /// Creates a new cache entry with an environment fingerprint. + package init( + viewSnapshot: Any, + buffer: FrameBuffer, + contextWidth: Int, + contextHeight: Int, + environmentFingerprint: EnvironmentFingerprint? + ) { self.viewSnapshot = viewSnapshot self.buffer = buffer self.contextWidth = contextWidth self.contextHeight = contextHeight + self.environmentFingerprint = environmentFingerprint } } @@ -188,6 +214,33 @@ extension RenderCache { view: V, contextWidth: Int, contextHeight: Int + ) -> FrameBuffer? { + lookup( + identity: identity, + view: view, + contextWidth: contextWidth, + contextHeight: contextHeight, + environmentFingerprint: nil + ) + } + + /// Looks up a cached buffer, additionally matching the environment + /// fingerprint captured when the entry was stored. + /// + /// - Parameters: + /// - identity: The view's structural identity. + /// - view: The current view value to compare against the snapshot. + /// - contextWidth: The current available width. + /// - contextHeight: The current available height. + /// - environmentFingerprint: The current position's environment + /// fingerprint; must equal the stored one for a hit. + /// - Returns: The cached ``FrameBuffer`` if valid, or `nil` on miss. + package func lookup( + identity: ViewIdentity, + view: V, + contextWidth: Int, + contextHeight: Int, + environmentFingerprint: EnvironmentFingerprint? ) -> FrameBuffer? { guard !effectBearingIdentities.contains(identity) else { stats.misses += 1 @@ -210,6 +263,11 @@ extension RenderCache { logDebug("MISS (size changed) \(identity.path)") return nil } + guard entry.environmentFingerprint == environmentFingerprint else { + stats.misses += 1 + logDebug("MISS (environment changed) \(identity.path)") + return nil + } guard oldView == view else { stats.misses += 1 logDebug("MISS (view changed) \(identity.path)") @@ -236,13 +294,43 @@ extension RenderCache { buffer: FrameBuffer, contextWidth: Int, contextHeight: Int + ) { + store( + identity: identity, + view: view, + buffer: buffer, + contextWidth: contextWidth, + contextHeight: contextHeight, + environmentFingerprint: nil + ) + } + + /// Stores a rendered buffer together with the environment fingerprint + /// of the rendering position. + /// + /// - Parameters: + /// - identity: The view's structural identity. + /// - view: The view value to snapshot for future comparisons. + /// - buffer: The rendered output to cache. + /// - contextWidth: The available width during rendering. + /// - contextHeight: The available height during rendering. + /// - environmentFingerprint: The environment fingerprint to require + /// on future lookups. + package func store( + identity: ViewIdentity, + view: V, + buffer: FrameBuffer, + contextWidth: Int, + contextHeight: Int, + environmentFingerprint: EnvironmentFingerprint? ) { stats.stores += 1 entries[identity] = CacheEntry( viewSnapshot: view, buffer: buffer, contextWidth: contextWidth, - contextHeight: contextHeight + contextHeight: contextHeight, + environmentFingerprint: environmentFingerprint ) logDebug("STORE \(identity.path)") } diff --git a/Tests/TUIkitTests/EquatableEffectTests.swift b/Tests/TUIkitTests/EquatableEffectTests.swift index a08012eb..2e0398f3 100644 --- a/Tests/TUIkitTests/EquatableEffectTests.swift +++ b/Tests/TUIkitTests/EquatableEffectTests.swift @@ -79,6 +79,31 @@ struct EquatableEffectTests { #expect(harness.tuiContext.renderCache.count == 2) } + + @Test("Foreground style changes invalidate cached content") + func foregroundStyleChangeInvalidatesEntry() { + let app = StyledCacheApp() + let harness = FrameHarness(app: app) + + harness.renderFrame() + harness.renderFrame() + + app.cell.color = .green + let statsBeforeChange = harness.tuiContext.renderCache.stats + harness.renderFrame() + let changeDelta = harness.tuiContext.renderCache.stats.delta(since: statsBeforeChange) + + // The style change must miss and re-render; a hit would serve a + // buffer rendered with the previous color. + #expect(changeDelta.hits == 0) + #expect(changeDelta.stores == 1) + + // With the style stable again, caching resumes. + let statsAfterChange = harness.tuiContext.renderCache.stats + harness.renderFrame() + let steadyDelta = harness.tuiContext.renderCache.stats.delta(since: statsAfterChange) + #expect(steadyDelta.hits == 1) + } } // MARK: - Fixtures @@ -154,6 +179,35 @@ private struct CachedFocusApp: App { } } +/// Mutable foreground color shared between a test and its app fixture. +@MainActor +private final class ColorCell { + var color: Color = .red +} + +/// Effect-free content whose parent applies a changing foreground style. +private struct StyledCachedContent: View, Equatable { + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { true } + + var body: some View { + Text("styled") + } +} + +private struct StyledCacheApp: App { + let cell = ColorCell() + + init() {} + + var body: some Scene { + WindowGroup { + StyledCachedContent() + .equatable() + .foregroundStyle(cell.color) + } + } +} + /// Declares a focus section (without focusables) inside cacheable content. private struct CachedSectionContent: View, Equatable { nonisolated static func == (lhs: Self, rhs: Self) -> Bool { true } From fbb126486df39b827fe75b5a93cc28fe848cf38d Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 02:26:09 +0200 Subject: [PATCH 7/7] Docs: Describe cache bypass, liveness, and fingerprint invalidation - Subtree Memoization rewrite: measure-pass exclusion, effect-bearing classification and bypass, liveness guarantees for hits, environment fingerprint in the cache key - Refresh the when-(not)-to-use guidance: effect-bearing subtrees are auto-bypassed and safe, wrapping them just adds nothing --- .../TUIkit.docc/Articles/RenderCycle.md | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md b/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md index a54bd2d1..45dff69a 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md @@ -436,11 +436,40 @@ While the view tree is reconstructed each frame, ``EquatableView`` allows **indi When a view is wrapped in `.equatable()`, the rendering system: -1. Looks up the cached ``FrameBuffer`` for this view's `ViewIdentity` -2. Compares the **current view value** with the cached snapshot via `Equatable.==` -3. Checks that the available **width and height** haven't changed -4. On **cache hit**: returns the cached buffer and preserves the subtree's State and Observation liveness without evaluating its body -5. On **cache miss**: renders normally and stores the result +1. Skips the cache entirely in `.measure` traversals: effect sites are inert during sizing, so a buffer stored there could let the same frame's output pass hit a subtree whose effects never mounted +2. Looks up the cached ``FrameBuffer`` for this view's `ViewIdentity` — identities classified as **effect-bearing** always miss (see below) +3. Compares the **current view value** with the cached snapshot via `Equatable.==` +4. Checks that the available **width and height** and the **environment fingerprint** (foreground style, focus indicator color) haven't changed +5. On **cache hit**: returns the cached buffer and preserves the subtree's State, Observation, and nested cache-entry liveness without evaluating its body +6. On **cache miss**: renders normally, classifies the content via the pass's effect-registration probe, and stores the result only when the rendering registered no effects + +### Effect-Bearing Subtrees + +Content that registers per-pass effects while rendering — key handlers, +focus registrations (`Button`, `Toggle`, …), focus sections, status-bar +declarations, preference writes, or lifetime-effect records (`onAppear`, +`.task`, …) — must reach the frame's collectors on **every** frame; a +cached buffer would silently drop those registrations at the frame commit. + +`EquatableView` therefore snapshots the pass's effect-registration probe +around every cache-miss rendering. Any delta flags the identity in the +`RenderCache`: flagged identities never produce hits, so the subtree +renders each frame and behaves exactly as if it were unwrapped (including +pulse-animated focus indicators). The classification refreshes on every +miss, so content that becomes effect-free re-enables caching automatically. + +This makes `.equatable()` **safe to apply anywhere**: only provably +effect-free output is ever served from the cache. On effect-bearing +subtrees the wrapper simply has no effect. + +### Liveness Guarantees + +A cache hit keeps every runtime record below the cached root alive without +traversing the subtree: `@State` boxes, Observation registrations, and +nested cache entries survive via subtree marking at the frame commit. +Effect-bearing records (lifecycle slots, tasks, handlers, status-bar items, +focus registrations) need no such marking — subtrees owning them never hit +the cache and re-register on every frame. ```swift // A static info box: title and subtitle are the only inputs. @@ -471,7 +500,8 @@ The runtime applies pending cache invalidations before rendering: | `@State` or `@AppStorage` change | Invalidates the owning view subtree | | Observed model change | Invalidates the structural subtree that read the dependency | | Language change | Requests a full runtime cache clear | -| Environment change | `RenderLoop` compares an `EnvironmentSnapshot` (palette ID + appearance ID) each frame and clears on mismatch | +| Global environment change | `RenderLoop` compares an `EnvironmentSnapshot` (palette ID + appearance ID) each frame and clears on mismatch | +| Style environment change | Entries store an environment fingerprint (foreground style, focus indicator color) captured at their tree position; a lookup with a differing fingerprint misses and re-renders | Each runtime owns its own `RenderCache`, so invalidation in one application cannot evict another application's cached output. Between invalidations, for example @@ -485,11 +515,12 @@ during Spinner animation frames, static subtrees are reused across frames. | Complex container hierarchies | Many nested views that produce the same output | | Views next to animated siblings | Spinner/Pulse re-renders the whole tree; static siblings benefit from caching | -| Bad candidates | Why | -|---------------|-----| +| Pointless candidates | Why | +|---------------------|-----| | Views that read `@State` directly | State lives in a reference-type box: the view struct compares as equal even when state changed | | Views that change every frame | Cache overhead with no benefit | | Tiny views (single `Text`) | Rendering cost is already minimal | +| Effect-bearing subtrees (interactive controls, lifecycle modifiers, status-bar declarations) | Automatically detected and bypassed — safe, but the wrapper adds nothing | ### Which Types Support `.equatable()` @@ -503,7 +534,7 @@ The following types have `Equatable` conformance, enabling `.equatable()` on vie **Supporting types:** `TextStyle`, `Alignment`, `ContainerConfig`, `ContainerStyle` -> Note: `Button` cannot be `Equatable` because it stores a closure (`action: () -> Void`). Views containing buttons are not candidates for `.equatable()`. +> Note: `Button` cannot be `Equatable` because it stores a closure (`action: () -> Void`). A custom `Equatable` view may still contain buttons — its focus registrations then classify the subtree as effect-bearing, and the cache is bypassed instead of dropping the button's interactivity. ### Debug Logging