You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On a cache hit, EquatableView marks only its wrapper identity active in Sources/TUIkitView/Core/EquatableView.swift:89-102,122-130. Each render pass then resets or prunes state, lifecycle, cache, handlers, focus, preferences, and other registrations in Sources/TUIkit/App/RenderLoop.swift:310-321,401-410.
A visually cached subtree can therefore lose descendant State, tasks, Observation subscriptions, focus/key handlers, preferences, header/status contributions, animations, and nested cache entries. The current tests inspect cache maps in isolation rather than an effect-bearing tree over repeated hits.
Proposed solution
Until correctness is proven, bypass caching for subtrees that may carry effects.
Represent cached subtree liveness and committed-effect manifests, or replay an equivalent committed tree record on a hit.
Include relevant size, proposal, Environment, style, and dependency reads in invalidation.
Compare cached and uncached execution through full runtime integration tests.
Acceptance criteria
A cache hit keeps every live descendant identity active.
State, tasks, lifecycle, Observation, focus, handlers, preferences, header/status, and animations behave identically with caching enabled or disabled.
Environment/style/size changes invalidate the affected cache entry.
Nested entries are retained and removed exactly with their keyed subtree.
No cache optimization is enabled for a subtree whose effect equivalence is unknown.
All Swift 6.0 macOS/Linux quality gates are warning-free.
Dependencies
Depends on the committed-effect transaction from P0-08.
Parallelization
Can run in parallel with per-app AppStorage once P0-08 is merged, but cache and committed-effect files must remain owned by this issue.
Commit structure
Split this issue into small, thematic, independently revertible commits wherever the work can remain coherent.
Every commit must build and keep its applicable tests/gates green. Do not commit an intentionally failing regression test; use local/known-issue characterization or land the test with the smallest fix.
Keep characterization/fixtures, mechanical renames or moves, semantic changes, and documentation/migration updates separate when each step remains green.
The issue boundary is not a commit boundary; multiple commits are expected for independently reversible changes.
Core insight: with per-pass collectors (#56) and the pending-effect log (#57), "does this subtree carry effects?" is measurable as counter deltas during the cache-MISS rendering. Strategy (as proposed above): bypass caching for effect-bearing subtrees, grant full liveness to effect-free ones. Effect-free subtrees cannot own lifecycle slots (those would be pending-log deltas), so no lifecycle subtree-marking is needed at all.
Task 1: End-to-end characterization over repeated hits
Create Tests/TUIkitTests/EquatableEffectTests.swift using FrameHarness (full RenderLoop pipeline). Pin today's losses with withKnownIssue referencing this issue:
A cached subtree with .onAppear/.onDisappear fires disappear (and cancels .task) on the second frame's cache hit.
A cached subtree's .onKeyPress handler and .statusBarItems declaration vanish after a hit (handlerCount, hasUserItems).
A cached subtree containing a focusable loses its focus registration after a hit (currentFocusedID).
A NESTED .equatable() entry under a cached root is garbage-collected on the outer hit (renderCache.count).
Migrate the existing marker test "Cached descendant lifecycle loss remains characterized for issue [P0-09] Preserve subtree liveness across EquatableView cache hits #14" (RuntimeCharacterizationTests.swift, live-path harness) to the FrameHarness end-to-end form; the live-path harness cannot observe collector deltas by design.
Commit: Test: Characterize effect loss across cache hits
Task 2: Effect-delta detection and cache bypass
Add test/diagnostic counters where missing: StatusBarState.passRegistrationCount, PreferenceStorage.writeCount (monotonic, incremented in setValue), FocusManager.stagedRegistrationCount. Existing: KeyEventDispatcher.handlerCount, PendingFrameEffects.deferredEffectCount.
EquatableView.renderToBuffer (miss path): snapshot the counters before rendering content, compare after. Any delta → store a carriesEffects marker for the identity instead of a reusable buffer entry (RenderCache gains the flag; lookup returns nil for flagged identities so the subtree always renders).
Hit path stays as is (markActive + markSubtreeActive via pending sets) — now provably sufficient because cached subtrees are effect-free.
Live path (no collectors in the environment): keep current behavior; bypass detection is a RenderLoop-pipeline guarantee.
Task 1 lifecycle/handler/status-bar/focus tests flip green → drop their markers.
Document the bypass in the EquatableView doc comment (what disables caching and why that is correct-by-construction).
Commit: Fix: Bypass subtree caching for effect-bearing content
Task 3: Nested cache liveness
RenderCache.markSubtreeActive(_ root:) (ancestor logic like clearAffected(by:)); TUIContext.applyFrameLiveness calls it for activeSubtreeRoots.
Task 1 nested-entry test flips green → drop marker.
Commit: Fix: Keep nested cache entries alive across outer hits
Task 4: Environment/style invalidation
Audit render-affecting Equatable environment reads inside cacheable content (grep environment. reads in Renderables; palette/appearance are already covered by the global EnvironmentSnapshot clear; pulsePhase is deliberately excluded — pulse-animated views are documented non-candidates).
Extend the cache entry with an EnvironmentFingerprint (small Equatable struct; expected members from the audit: foregroundStyle, focusIndicatorColor, plus whatever the audit surfaces); mismatch → miss.
Test: wrapping a cached view in .foregroundColor(...) whose value changes between frames invalidates the entry.
Commit: Fix: Include style environment in cache invalidation
Task 5: Docs, gates, PR
Update the "Subtree Memoization" section in RenderCycle.md: bypass semantics, liveness guarantees, fingerprint invalidation; refresh the "when (not) to use" guidance.
./scripts/test-linux.sh green on macOS + Linux; DocC diagnostics-free; API manifest untouched or regenerated via tool (goal: package/internal only).
PR against main; this issue closes on merge.
Verified facts (audited 2026-07-22 on main @ da0c247)
Hit path marks only wrapper + subtree state/observation: EquatableView.swift (markActive, markSubtreeActive → PendingFrameEffects sets); applyFrameLiveness covers stateStorage/observationRegistry/renderCache.markActive(root) — no nested-entry subtree marking (TUIContext.swift).
RenderCache has markActive/removeInactive/clearAffected(by:) (ancestor logic exists) but no markSubtreeActive (RenderCache.swift).
LifecycleManager slots are "identity:<path>" strings; no subtree queries — not needed under the bypass strategy (TUIContext.swift).
ViewIdentity.isAncestor(of:) matches /- and #-separated descendants; scoped slots (/@hex) are path children and therefore covered (ViewIdentity.swift:104-118).
Problem
On a cache hit,
EquatableViewmarks only its wrapper identity active inSources/TUIkitView/Core/EquatableView.swift:89-102,122-130. Each render pass then resets or prunes state, lifecycle, cache, handlers, focus, preferences, and other registrations inSources/TUIkit/App/RenderLoop.swift:310-321,401-410.A visually cached subtree can therefore lose descendant State, tasks, Observation subscriptions, focus/key handlers, preferences, header/status contributions, animations, and nested cache entries. The current tests inspect cache maps in isolation rather than an effect-bearing tree over repeated hits.
Proposed solution
Acceptance criteria
Dependencies
Depends on the committed-effect transaction from P0-08.
Parallelization
Can run in parallel with per-app AppStorage once P0-08 is merged, but cache and committed-effect files must remain owned by this issue.
Commit structure
Plan (2026-07-22, builds on the #13 architecture)
Core insight: with per-pass collectors (#56) and the pending-effect log (#57), "does this subtree carry effects?" is measurable as counter deltas during the cache-MISS rendering. Strategy (as proposed above): bypass caching for effect-bearing subtrees, grant full liveness to effect-free ones. Effect-free subtrees cannot own lifecycle slots (those would be pending-log deltas), so no lifecycle subtree-marking is needed at all.
Task 1: End-to-end characterization over repeated hits
Create
Tests/TUIkitTests/EquatableEffectTests.swiftusingFrameHarness(fullRenderLooppipeline). Pin today's losses withwithKnownIssuereferencing this issue:.onAppear/.onDisappearfires disappear (and cancels.task) on the second frame's cache hit..onKeyPresshandler and.statusBarItemsdeclaration vanish after a hit (handlerCount,hasUserItems).currentFocusedID)..equatable()entry under a cached root is garbage-collected on the outer hit (renderCache.count).FrameHarnessend-to-end form; the live-path harness cannot observe collector deltas by design.Test: Characterize effect loss across cache hitsTask 2: Effect-delta detection and cache bypass
StatusBarState.passRegistrationCount,PreferenceStorage.writeCount(monotonic, incremented insetValue),FocusManager.stagedRegistrationCount. Existing:KeyEventDispatcher.handlerCount,PendingFrameEffects.deferredEffectCount.EquatableView.renderToBuffer(miss path): snapshot the counters before renderingcontent, compare after. Any delta → store acarriesEffectsmarker for the identity instead of a reusable buffer entry (RenderCachegains the flag;lookupreturns nil for flagged identities so the subtree always renders).markActive+markSubtreeActivevia pending sets) — now provably sufficient because cached subtrees are effect-free.EquatableViewdoc comment (what disables caching and why that is correct-by-construction).Fix: Bypass subtree caching for effect-bearing contentTask 3: Nested cache liveness
RenderCache.markSubtreeActive(_ root:)(ancestor logic likeclearAffected(by:));TUIContext.applyFrameLivenesscalls it foractiveSubtreeRoots.Fix: Keep nested cache entries alive across outer hitsTask 4: Environment/style invalidation
Equatableenvironment reads inside cacheable content (grepenvironment.reads in Renderables; palette/appearance are already covered by the globalEnvironmentSnapshotclear;pulsePhaseis deliberately excluded — pulse-animated views are documented non-candidates).EnvironmentFingerprint(smallEquatablestruct; expected members from the audit:foregroundStyle,focusIndicatorColor, plus whatever the audit surfaces); mismatch → miss..foregroundColor(...)whose value changes between frames invalidates the entry.Fix: Include style environment in cache invalidationTask 5: Docs, gates, PR
RenderCycle.md: bypass semantics, liveness guarantees, fingerprint invalidation; refresh the "when (not) to use" guidance../scripts/test-linux.shgreen on macOS + Linux; DocC diagnostics-free; API manifest untouched or regenerated via tool (goal:package/internal only).main; this issue closes on merge.Verified facts (audited 2026-07-22 on main @ da0c247)
markActive,markSubtreeActive→PendingFrameEffectssets);applyFrameLivenesscoversstateStorage/observationRegistry/renderCache.markActive(root)— no nested-entry subtree marking (TUIContext.swift).RenderCachehasmarkActive/removeInactive/clearAffected(by:)(ancestor logic exists) but nomarkSubtreeActive(RenderCache.swift).LifecycleManagerslots are"identity:<path>"strings; no subtree queries — not needed under the bypass strategy (TUIContext.swift).ViewIdentity.isAncestor(of:)matches/- and#-separated descendants; scoped slots (/@hex) are path children and therefore covered (ViewIdentity.swift:104-118).KeyEventDispatcher.handlerCount(KeyEvent.swift),PendingFrameEffects.deferredEffectCount(PendingFrameEffects.swift),LifecycleManager.taskCount/disappearCallbackCount(TUIContext.swift).Equatableview snapshot + contextWidth/Height (RenderCache.lookup); global palette/appearance invalidation viaEnvironmentSnapshot(RenderLoop.swift).lifecycle.recordAppearcalls.FrameHarness+ fixtures (GrowableHeaderModel,HeightGate) in Tests/TUIkitTests/Support/ ready for end-to-end frames.