diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index 31681e50..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 @@ -349,6 +377,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 +396,21 @@ 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 + } + environment.environmentFingerprintProbe = { environment in + EnvironmentFingerprint(StyleEnvironmentFingerprint(from: environment)) + } return environment } } 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/TUIkit/Focus/Focus.swift b/Sources/TUIkit/Focus/Focus.swift index 36931000..0c73ebdb 100644 --- a/Sources/TUIkit/Focus/Focus.swift +++ b/Sources/TUIkit/Focus/Focus.swift @@ -87,6 +87,18 @@ public final class FocusManager: @unchecked Sendable { /// semantics. private var isCollectingPass = false + /// Number of sections and focusables staged by the current collecting + /// pass. + /// + /// Snapshot around a subtree rendering to detect focus registrations + /// (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(stagedSections.count) { $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/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 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..71f3bf64 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,12 +116,30 @@ 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) + } + + // 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. @@ -108,16 +148,27 @@ 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, + environmentFingerprint: environmentFingerprint + ) + } return buffer } @@ -129,14 +180,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/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/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 b29b8466..d2d73128 100644 --- a/Sources/TUIkitView/Rendering/RenderCache.swift +++ b/Sources/TUIkitView/Rendering/RenderCache.swift @@ -124,18 +124,52 @@ 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 } } /// 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 +215,38 @@ extension RenderCache { 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 + logDebug("MISS (carries effects) \(identity.path)") + return nil + } guard let entry = entries[identity] else { stats.misses += 1 logDebug("MISS (no entry) \(identity.path)") @@ -197,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)") @@ -223,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)") } @@ -244,6 +345,51 @@ 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. + /// + /// 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 +406,9 @@ extension RenderCache { for key in staleKeys { entries.removeValue(forKey: key) } + effectBearingIdentities = effectBearingIdentities.filter { + activeIdentities.contains($0) + } } /// Clears all cached entries. @@ -297,6 +446,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 new file mode 100644 index 00000000..2e0398f3 --- /dev/null +++ b/Tests/TUIkitTests/EquatableEffectTests.swift @@ -0,0 +1,258 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// EquatableEffectTests.swift +// +// License: MIT + +import Testing +import TUIkitTestSupport + +@testable import TUIkit + +/// Specifies effect behavior across `EquatableView` cache hits (issue #14), +/// end-to-end through the `RenderLoop` pipeline. +/// +/// 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. Nested +/// cache entries below an effect-free cached root survive outer hits via +/// `RenderCache.markSubtreeActive`. +@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() + #expect(events.filter { $0 == "appear" }.count == 1) + #expect(events.contains("disappear") == false) + #expect(harness.tuiContext.lifecycle.taskCount == 1) + + 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() + + #expect(harness.tuiContext.keyEventDispatcher.handlerCount == 1) + #expect(harness.tuiContext.statusBar.hasUserItems) + } + + @Test("Cache hits keep focus registrations alive") + func cacheHitKeepsFocus() { + let harness = FrameHarness(app: CachedFocusApp()) + + harness.renderFrame() + harness.renderFrame() + + #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()) + + harness.renderFrame() + harness.renderFrame() + + #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 + +/// 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() + } + } +} + +/// 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 } + + 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 } + + 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..74c1b04c 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -223,34 +223,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